Timing and tasks: the scheduler
Run code later, repeat it every few seconds, and do slow work without freezing the server.
Plugins often need to do something later, over and over, or without freezing the game. On this page you will delay a message, repeat a task every few seconds, build a countdown with a boss bar, cancel tasks properly, and move slow work off the main thread so the server never stutters.
Why plugins need a scheduler
Your plugin's code only runs when Paper calls it: when the plugin starts, when an event happens, when a command is typed. Between those moments, nothing of yours is running. So how do you say "send this message in 10 seconds" or "heal the player once per second"?
You cannot write "wait 10 seconds" in the middle of a method, as you will see in a moment. Instead you ask Paper's scheduler to run a piece of code later. You hand it the code and a time, and Paper calls it back when the time comes. Each piece of scheduled code is called a task.
Time in Minecraft is measured in ticks
The server does its work in small steps called ticks. Every tick, Paper moves mobs, grows crops, runs redstone and calls your tasks. There are 20 ticks every second, so one tick lasts 50 milliseconds. All scheduler methods count in ticks, not seconds.
| Time | Ticks | How to write it in code |
|---|---|---|
| 1 tick | 1 | 1L |
| 1 second | 20 | 20L |
| 5 seconds | 100 | 5 * 20L |
| 1 minute | 1,200 | 60 * 20L |
| 5 minutes | 6,000 | 5 * 60 * 20L |
| 1 hour | 72,000 | 60 * 60 * 20L |
The scheduler wants a long (a big whole number), which is why the numbers end in L. Writing 5 * 20L instead of 100L makes the code explain itself. Even better, give the number a name, as the examples below do with TICKS_PER_SECOND. The Tick Calculator converts any time for you.
Your first tasks: later, and again and again
This small plugin does two things for every player who joins. After 3 seconds it welcomes them, and every 10 seconds it repeats a tip until they earn their first experience level. First the listener, then the main class.
Where this file livesscheduler-firstsrcmainjavacomexampleschedulerfirstNudgeListener.java
The package com.example.schedulerfirst is the folder path com/example/schedulerfirst inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-first/
- src/main/
- java/com/example/schedulerfirst/Package com.example.schedulerfirst
- NudgeListener.javayou are hereListener: reacts to events
- SchedulerFirstPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerfirst/Package com.example.schedulerfirst
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.schedulerfirst;2 3import org.bukkit.entity.Player;4import org.bukkit.event.EventHandler;5import org.bukkit.event.Listener;6import org.bukkit.event.player.PlayerJoinEvent;7import org.bukkit.plugin.java.JavaPlugin;8 9public final class NudgeListener implements Listener {10 11 private static final long TICKS_PER_SECOND = 20L;12 13 private final JavaPlugin plugin;14 15 public NudgeListener(JavaPlugin plugin) {16 this.plugin = plugin;17 }18 19 @EventHandler20 public void onJoin(PlayerJoinEvent event) {21 Player player = event.getPlayer();22 23 plugin.getServer().getScheduler().runTaskLater(plugin, () -> {24 if (player.isOnline()) {25 player.sendRichMessage("<green>Glad you stayed. Have a look around!");26 }27 }, 3 * TICKS_PER_SECOND);28 29 plugin.getServer().getScheduler().runTaskTimer(plugin, task -> {30 if (!player.isOnline() || player.getLevel() > 0) {31 task.cancel();32 return;33 }34 player.sendRichMessage("<gray>Tip: mine some ore or defeat a mob to earn your first experience level.");35 }, 5 * TICKS_PER_SECOND, 10 * TICKS_PER_SECOND);36 }37}- A named
constant . Using it keeps the numbers below readable:3 * TICKS_PER_SECONDis obviously "3 seconds". - The scheduler needs to know which plugin owns a task, so the listener keeps a reference to the plugin. The main class passes
thisin. - Schedules one run, later. The arguments are: the owning plugin, the code to run, and the delay in ticks (the last argument, after the closing brace).
- A
lambda : a small piece of code without a name that you can hand to another method. Here it means "do this when the time comes". It has no parameters, so the parentheses are empty. - Three seconds is a long time. The player may have left, so check before sending. A task cannot know that on its own.
- Schedules a repeating task. This form of the lambda receives the
taskitself, so the code can cancel itself. - Stops this repeating task for good. It will not run again. Without this line the tip would repeat until the server stops.
- The first number is the
delay before the first run (5 seconds). The second is theperiod , the time between runs (10 seconds).
Where this file livesscheduler-firstsrcmainjavacomexampleschedulerfirstSchedulerFirstPlugin.java
The package com.example.schedulerfirst is the folder path com/example/schedulerfirst inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-first/
- src/main/
- java/com/example/schedulerfirst/Package com.example.schedulerfirst
- NudgeListener.javaListener: reacts to events
- SchedulerFirstPlugin.javayou are hereMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerfirst/Package com.example.schedulerfirst
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.schedulerfirst;2 3import org.bukkit.plugin.java.JavaPlugin;4 5public final class SchedulerFirstPlugin extends JavaPlugin {6 7 @Override8 public void onEnable() {9 getServer().getPluginManager().registerEvents(new NudgeListener(this), this);10 getServer().getScheduler().runTaskLater(this,11 () -> getLogger().info("Five seconds have passed since the plugin started."), 100L);12 }13}- Hands the listener the plugin (
this) it needs for scheduling. - The same method, called straight from
onEnable.100Lticks is 5 seconds. - Prints to the server console, so you can see the delayed task ran.
Join the test server and, after three seconds, this appears. If your level is still zero, the tip follows 5 seconds after joining, then every 10 seconds:
Tip: mine some ore or defeat a mob to earn your first experience level.
[14:02:14 INFO]: [SchedulerFirst] Enabling SchedulerFirst v1.0.0[14:02:19 INFO]: [SchedulerFirst] Five seconds have passed since the plugin started.Delay and period
Every scheduler method is a mix of these two numbers. The delay is how long to wait before the first run. The period is how long to wait between the following runs. A "later" task has only a delay; a repeating task has both.
These are the methods you will use all the time. They all live on the BukkitScheduler, which you get with getServer().getScheduler(). Every one takes your plugin as the first argument.
| Method | What it does |
|---|---|
runTask(plugin, task) | Runs once, as soon as possible (on the next tick). |
runTaskLater(plugin, task, delay) | Runs once after the delay. |
runTaskTimer(plugin, task, delay, period) | Runs after the delay, then repeats every period, until canceled. |
runTaskAsynchronously(plugin, task) | Runs once on a different thread (see below). |
runTaskLaterAsynchronously(...) and runTaskTimerAsynchronously(...) | The same, on a different thread. |
Three ways to write the task
The task argument can take three shapes, and you will meet all of them in other people's plugins. Which one you choose depends on what the task needs to remember.
- A lambda:
() -> player.sendRichMessage("Hi"). Best for short, one-off code, like the delayed welcome above. - A lambda with a task parameter:
task -> { ... task.cancel(); }. Java calls this form aConsumer: code that receives one value, here the running task. Best for a repeating task that decides by itself when it is finished, like the tip. This form returns nothing, so use one of the other two shapes when you want to save theBukkitTaskfor later. - A class that
implements Runnableor extendsBukkitRunnable. Best when the task has its own variables or is long enough to deserve a name.
The auto-broadcast in the demo plugin is the third kind. It is a class that implements Runnable, a promise that it has a run() method. Scheduler calls run() every period:
Where this file livesscheduler-demosrcmainjavacomexampleschedulerdemoAutoBroadcast.java
The package com.example.schedulerdemo is the folder path com/example/schedulerdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-demo/
- src/main/
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- AutoBroadcast.javayou are hereHelper class
- CountdownCommand.javaCommand (BasicCommand)
- CountdownTask.javaHelper class
- PrimeCounter.javaHelper class
- PrimesCommand.javaCommand (BasicCommand)
- RemindCommand.javaCommand (BasicCommand)
- SchedulerDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.schedulerdemo;2 3import java.util.List;4import net.kyori.adventure.text.minimessage.MiniMessage;5import org.bukkit.Bukkit;6 7final class AutoBroadcast implements Runnable {8 9 private final List<String> messages;10 private int nextIndex;11 12 AutoBroadcast(List<String> messages) {13 this.messages = List.copyOf(messages);14 }15 16 @Override17 public void run() {18 if (messages.isEmpty() || Bukkit.getOnlinePlayers().isEmpty()) {19 return;20 }21 Bukkit.getServer().sendMessage(MiniMessage.miniMessage().deserialize(messages.get(nextIndex)));22 nextIndex = (nextIndex + 1) % messages.size();23 }24}- A
Runnableis anything with arun()method and no parameters. The scheduler accepts it as a task. - The task's memory. Each run shows the next message, so it must remember where it stopped. This is the reason to use a class instead of a lambda.
- Makes a private, unchangeable copy, so nobody can change the list while the task is using it.
- Nobody to hear the message? Skip this round and save the effort.
- Turns the MiniMessage text into a colored chat message and sends it to everyone, including the console.
- Moves to the next message and wraps to 0 after the last one. The
%(remainder) operator is the trick: with 3 messages the index goes 0, 1, 2, 0, 1, 2 and so on.
The main class starts it. Look at the arguments: the first run comes after 60 seconds, then one every 5 minutes.
Where this file livesscheduler-demosrcmainjavacomexampleschedulerdemoSchedulerDemoPlugin.java
The package com.example.schedulerdemo is the folder path com/example/schedulerdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-demo/
- src/main/
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- AutoBroadcast.javaHelper class
- CountdownCommand.javaCommand (BasicCommand)
- CountdownTask.javaHelper class
- PrimeCounter.javaHelper class
- PrimesCommand.javaCommand (BasicCommand)
- RemindCommand.javaCommand (BasicCommand)
- SchedulerDemoPlugin.javayou are hereMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
14@Override15public void onEnable() {16 List<String> tips = List.of(17 "<gray>Tip: <white>/countdown 10</white> starts a boss bar countdown.",18 "<gray>Tip: <white>/primes 5000000</white> counts primes without freezing the server.",19 "<gray>Tip: <white>/remind 30 stretch</white> sends you a reminder later.");20 autoBroadcastTask = getServer().getScheduler().runTaskTimer(21 this, new AutoBroadcast(tips), 60 * TICKS_PER_SECOND, 5 * 60 * TICKS_PER_SECOND);22 23 getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event -> {24 event.registrar().register("countdown", "Show a boss bar countdown", new CountdownCommand(this));25 event.registrar().register("primes", "Count primes on another thread", new PrimesCommand(this));26 event.registrar().register("remind", "Send yourself a reminder later", new RemindCommand(this));27 });28}- Starts the repeating task and returns a
BukkitTask. The next section explains why the result is saved in a field. - One object that lives as long as the task does, so its
nextIndexkeeps counting. - Delay of one minute, period of five minutes.
Tip: /primes 5000000 counts primes without freezing the server.
Tip: /remind 30 stretch sends you a reminder later.
A task with state: BukkitRunnable
Many tasks count something: seconds left, blocks placed, loops done. Tasks like that belong in a class, because a class can have variables that survive between runs. Bukkit provides a ready-made base class for this, BukkitRunnable. It is a Runnable that also knows how to schedule and cancel itself, so inside run() you can simply write cancel().
This is the countdown from the demo plugin. It shows a boss bar that shrinks every second, plays a tick sound, and finishes with a title:
Where this file livesscheduler-demosrcmainjavacomexampleschedulerdemoCountdownTask.java
The package com.example.schedulerdemo is the folder path com/example/schedulerdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-demo/
- src/main/
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- AutoBroadcast.javaHelper class
- CountdownCommand.javaCommand (BasicCommand)
- CountdownTask.javayou are hereHelper class
- PrimeCounter.javaHelper class
- PrimesCommand.javaCommand (BasicCommand)
- RemindCommand.javaCommand (BasicCommand)
- SchedulerDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1package com.example.schedulerdemo;2 3import java.util.HashSet;4import java.util.List;5import java.util.Set;6import net.kyori.adventure.bossbar.BossBar;7import net.kyori.adventure.text.Component;8import net.kyori.adventure.text.format.NamedTextColor;9import net.kyori.adventure.title.Title;10import org.bukkit.Sound;11import org.bukkit.entity.Player;12import org.bukkit.plugin.Plugin;13import org.bukkit.scheduler.BukkitRunnable;14 15final class CountdownTask extends BukkitRunnable {16 17 private static final long TICKS_PER_SECOND = 20L;18 private static final Set<CountdownTask> RUNNING = new HashSet<>();19 20 private final Player player;21 private final int totalSeconds;22 private final BossBar bar;23 private int remaining;24 25 private CountdownTask(Player player, int seconds) {26 this.player = player;27 this.totalSeconds = seconds;28 this.remaining = seconds;29 this.bar = BossBar.bossBar(Component.empty(), 1.0f, BossBar.Color.GREEN, BossBar.Overlay.NOTCHED_10);30 }31 32 static void start(Plugin plugin, Player player, int seconds) {33 CountdownTask task = new CountdownTask(player, seconds);34 player.showBossBar(task.bar);35 task.runTaskTimer(plugin, 0L, TICKS_PER_SECOND);36 RUNNING.add(task);37 }38 39 static void cancelAll() {40 for (CountdownTask task : List.copyOf(RUNNING)) {41 task.cancel();42 }43 }44 45 @Override46 public void run() {47 if (!player.isOnline()) {48 cancel();49 return;50 }51 if (remaining <= 0) {52 player.showTitle(Title.title(Component.text("Go!", NamedTextColor.GOLD), Component.empty()));53 player.playSound(player, Sound.ENTITY_PLAYER_LEVELUP, 1.0f, 1.0f);54 cancel();55 return;56 }57 bar.name(Component.text("Starting in " + remaining + "s", NamedTextColor.WHITE));58 bar.progress((float) remaining / totalSeconds);59 bar.color(remaining <= 3 ? BossBar.Color.RED : BossBar.Color.GREEN);60 player.playSound(player, Sound.BLOCK_NOTE_BLOCK_HAT, 0.7f, remaining <= 3 ? 1.6f : 1.0f);61 remaining--;62 }63 64 @Override65 public void cancel() {66 super.cancel();67 player.hideBossBar(bar);68 RUNNING.remove(this);69 }70}- The task inherits
runTaskTimerandcancelfromBukkitRunnable, and must providerun()itself. - A shared list of all countdowns that are currently active. It exists so the plugin can stop them all when it shuts down.
- The state: how many seconds are left. A lambda could not keep this as easily.
- The constructor is private, so the only way to start a countdown is the
startmethod below, which sets everything up in the right order. - Creates the task, shows the boss bar and schedules the task with
runTaskTimer(plugin, 0L, TICKS_PER_SECOND): no delay, then once per second. - On a
BukkitRunnableyou do not pass the task, because the object itself is the task: just the plugin, the delay and the period. - Cancel removes the task from
RUNNING. Looping over a list while removing from it crashes Java, so the loop goes over a copy. - The player left mid-countdown. Cancel, or the task would keep running for nobody.
- The finish line: show "Go!", play a sound and cancel. Nothing else will run after
cancel(), and thereturnmakes sure the rest of this method is skipped too. - A number from 0.0 to 1.0. Dividing the seconds left by the total makes the bar shrink evenly. The
(float)in front stops Java from doing whole-number division, which would give 0. - Subtracts one from the seconds left, ready for the next run.
- Overrides
cancel()to clean up when the countdown ends: stop the task, hide the boss bar and remove it from the list.
The command that starts it checks the input first, because players type anything:
Where this file livesscheduler-demosrcmainjavacomexampleschedulerdemoCountdownCommand.java
The package com.example.schedulerdemo is the folder path com/example/schedulerdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-demo/
- src/main/
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- AutoBroadcast.javaHelper class
- CountdownCommand.javayou are hereCommand (BasicCommand)
- CountdownTask.javaHelper class
- PrimeCounter.javaHelper class
- PrimesCommand.javaCommand (BasicCommand)
- RemindCommand.javaCommand (BasicCommand)
- SchedulerDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
16@Override17public void execute(CommandSourceStack source, String[] args) {18 if (!(source.getExecutor() instanceof Player player)) {19 source.getSender().sendRichMessage("<red>Only players can watch a boss bar.");20 return;21 }22 if (args.length != 1) {23 player.sendRichMessage("<red>Usage: /countdown <seconds>");24 return;25 }26 int seconds;27 try {28 seconds = Integer.parseInt(args[0]);29 } catch (NumberFormatException exception) {30 player.sendRichMessage("<red>That is not a whole number.");31 return;32 }33 if (seconds < 1 || seconds > 300) {34 player.sendRichMessage("<red>Pick a number of seconds from 1 to 300.");35 return;36 }37 CountdownTask.start(plugin, player, seconds);38}- Only a player has a screen, so only a player gets a boss bar. The console sees an error message instead.
- Turns the text the player typed into a number. If it is not a number, Java throws a
NumberFormatException, and thecatchshows a friendly message instead of crashing. - Never trust input. A countdown of 2 billion seconds would just waste memory.
- Everything is valid, so start the countdown.
Canceling tasks
A repeating task runs until something stops it. There are four ways to stop one:
- From inside: call
task.cancel()in theConsumerform, orcancel()in aBukkitRunnable. This is the cleanest way, and the one the examples use. - From outside, with a saved task: the
RunnableandBukkitRunnableforms ofrunTaskTimerreturn aBukkitTask, a handle for the running task. Save it in a field and callcancel()on it later, as the auto-broadcast does. - By number: every task has an id.
getScheduler().cancelTask(id)stops it. - All at once:
getScheduler().cancelTasks(plugin)stops every task your plugin owns.
Where this file livesscheduler-demosrcmainjavacomexampleschedulerdemoSchedulerDemoPlugin.java
The package com.example.schedulerdemo is the folder path com/example/schedulerdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-demo/
- src/main/
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- AutoBroadcast.javaHelper class
- CountdownCommand.javaCommand (BasicCommand)
- CountdownTask.javaHelper class
- PrimeCounter.javaHelper class
- PrimesCommand.javaCommand (BasicCommand)
- RemindCommand.javaCommand (BasicCommand)
- SchedulerDemoPlugin.javayou are hereMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
30@Override31public void onDisable() {32 if (autoBroadcastTask != null) {33 autoBroadcastTask.cancel();34 }35 CountdownTask.cancelAll();36}- If
onEnablefailed early, the field was never filled in, and callingcancel()on nothing would crash. - Stops the repeating broadcast through the saved
BukkitTask. - Ends every running countdown, which also removes their boss bars from players' screens.
When your plugin is disabled, for example when the server stops, Paper cancels all of its tasks for you. So why does onDisable still clean up? Because canceling the task does not clean up what the task left behind. A boss bar that was shown would stay on a player's screen, frozen, until they relog. Cancel your own tasks when you need that cleanup to happen, and always remove what they created.
Slow work: async tasks
Here is the most important idea on this page. Almost everything your plugin does runs on the main thread, the single thread that also moves every mob and loads every chunk. A thread is one line of work the computer follows; the main thread has 50 ms per tick for everything. If your code takes 2 seconds, the whole server freezes for 2 seconds: nobody can move, mobs stop, and players see "Can't keep up!" in the console.
The fix is to run slow work on another thread, called an asynchronous ("async") task. The main thread carries on with the game while your work happens elsewhere. runTaskAsynchronously does exactly that.
The catch: the Bukkit API is not safe to use off the main thread. Two threads changing the same chest or the same entity at once corrupt data and crash the server in strange ways. So an async task follows a simple rule:
| Safe on an async thread | Not safe: do it on the main thread |
|---|---|
| Math, loops and your own objects that nobody else touches | Changing blocks, entities, worlds or inventories |
| Reading and writing your own files | Teleporting a player, changing their health or items |
| Database queries and web requests | Looking around the world (getBlockAt, nearby entities) |
| Plain text, numbers and copies of data | Almost every method of Paper's API, including sending messages to a player |
The pattern always has three steps. Collect what you need on the main thread, do the slow work async, and hop back to the main thread with runTask to use the result.
Where this file livesscheduler-demosrcmainjavacomexampleschedulerdemoPrimesCommand.java
The package com.example.schedulerdemo is the folder path com/example/schedulerdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-demo/
- src/main/
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- AutoBroadcast.javaHelper class
- CountdownCommand.javaCommand (BasicCommand)
- CountdownTask.javaHelper class
- PrimeCounter.javaHelper class
- PrimesCommand.javayou are hereCommand (BasicCommand)
- RemindCommand.javaCommand (BasicCommand)
- SchedulerDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
17@Override18public void execute(CommandSourceStack source, String[] args) {19 CommandSender sender = source.getSender();20 if (args.length != 1) {21 sender.sendRichMessage("<red>Usage: /primes <limit>");22 return;23 }24 int limit;25 try {26 limit = Integer.parseInt(args[0]);27 } catch (NumberFormatException exception) {28 sender.sendRichMessage("<red>That is not a whole number.");29 return;30 }31 if (limit < 10 || limit > 50_000_000) {32 sender.sendRichMessage("<red>Pick a limit from 10 to 50,000,000.");33 return;34 }35 36 sender.sendRichMessage("<gray>Counting primes up to <white><limit></white> in the background...",37 Placeholder.unparsed("limit", String.valueOf(limit)));38 39 plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> {40 long startedAt = System.nanoTime();41 int count = PrimeCounter.countUpTo(limit);42 long millis = (System.nanoTime() - startedAt) / 1_000_000;43 44 plugin.getServer().getScheduler().runTask(plugin, () -> sender.sendRichMessage(45 "<green>Found <white><count></white> primes up to <white><limit></white> in <white><time> ms</white>.",46 Placeholder.unparsed("count", String.valueOf(count)),47 Placeholder.unparsed("limit", String.valueOf(limit)),48 Placeholder.unparsed("time", String.valueOf(millis))));49 });50}- The command still runs on the main thread here. Anything you need from the game, like the sender, is collected now.
- A
limitvariable that is assigned once is allowed inside the lambda below, because Java lets lambdas use variables that never change. - From here to the closing brace the code runs on another thread. The server keeps ticking.
- A very precise stopwatch, in nanoseconds (billionths of a second). It is plain Java, not Paper API, so it is fine anywhere.
- The slow part: counting primes up to 50 million takes a noticeable moment. It touches no Paper API at all, which is why it is safe here.
- The hop back. This second lambda runs on the main thread, where it is safe to message a player.
- Fills the
<count>,<limit>and<time>tags in the message with the real values.
Found 348513 primes up to 5000000 in 42 ms.
While the background count runs, the server keeps its 20 ticks per second, and other players do not notice anything. The time in the message is just what this computer measured; yours will differ.
Moving work between threads has more rules and some traps. Threads, performance and lag goes deeper, including how to find out which code makes your server lag.
Never sleep on the main thread
Beginners often try the obvious way to wait: Java has Thread.sleep(5000), which pauses the current thread for 5 seconds. Inside a command or event handler, that is the main thread, so the whole server pauses for 5 seconds.
1@EventHandler2public void onJoin(PlayerJoinEvent event) throws InterruptedException {3 Thread.sleep(5000);4 event.getPlayer().sendRichMessage("<green>Welcome!");5}Everyone on the server freezes while this waits, and a watchdog may even crash the server after a while. Schedule instead: runTaskLater(plugin, () -> ..., 100L) does the same job without stopping anything. The server keeps running during the wait and calls you back.
Newer schedulers: global, entity and async
You will also see three newer schedulers in Paper code. They come from Folia, a Paper fork that splits the world into regions and runs them on many threads at once. Paper itself still has one main thread, but it offers the same API, so plugins written this way work on both. They differ from the classic scheduler in a few ways: tasks are passed as a Consumer<ScheduledTask>, delays are at least 1 tick, and the task you get back is a ScheduledTask.
| Scheduler | How to get it | Use it for |
|---|---|---|
| Global region | getServer().getGlobalRegionScheduler() | Server-wide things with no particular location, such as the auto-broadcast. |
| Entity | entity.getScheduler() (works for players) | Anything about one entity. Runs where that entity is, and stops by itself if the entity is removed. |
| Async | getServer().getAsyncScheduler() | Slow work that must not touch the game, like runTaskAsynchronously. |
The /remind command in the demo plugin uses the entity scheduler. Here is the part that schedules:
Where this file livesscheduler-demosrcmainjavacomexampleschedulerdemoRemindCommand.java
The package com.example.schedulerdemo is the folder path com/example/schedulerdemo inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-demo/
- src/main/
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- AutoBroadcast.javaHelper class
- CountdownCommand.javaCommand (BasicCommand)
- CountdownTask.javaHelper class
- PrimeCounter.javaHelper class
- PrimesCommand.javaCommand (BasicCommand)
- RemindCommand.javayou are hereCommand (BasicCommand)
- SchedulerDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
46player.getScheduler().runDelayed(plugin,47 task -> player.sendRichMessage("<gold>Reminder:</gold> <white><message>",48 Placeholder.unparsed("message", message)),49 null,50 seconds * TICKS_PER_SECOND);- Each player has its own scheduler. Tasks on it are tied to that player.
- Runs the task once after a delay. Compare it to
runTaskLater: the order of arguments is similar, but the delay is the last argument. - The task receives a
ScheduledTask. This one ignores it, since a single run has nothing to cancel. - The "retired" callback: code to run instead if the player is gone before the task is due.
nullmeans do nothing. - The delay, in ticks, as always.
The other two read very much alike. These short snippets are correct as written inside your main plugin class, where this is the plugin, once TimeUnit is imported from java.util.concurrent:
1getServer().getGlobalRegionScheduler().runAtFixedRate(this, task -> {2 getServer().sendRichMessage("<gray>The server is still running.");3}, 20L, 6000L);1getServer().getAsyncScheduler().runDelayed(this, task -> {2 getLogger().info("Five seconds later, on another thread.");3}, 5, TimeUnit.SECONDS);The async scheduler takes real time (a number plus a TimeUnit such as TimeUnit.SECONDS) instead of ticks, because async work has no connection to the tick loop. For a normal Paper plugin, the classic BukkitScheduler is perfectly fine and what most tutorials use. Learn the newer ones if you plan to support Folia, or when you want tasks tied to an entity.
The whole demo plugin
- scheduler-demo/
- src/
- main/
- java/
- com/example/schedulerdemo/
- SchedulerDemoPlugin.javaStarts the auto-broadcast and registers the three commands
- AutoBroadcast.javaA Runnable class that rotates through the tips
- CountdownTask.javaA BukkitRunnable with a boss bar and a seconds counter
- CountdownCommand.java/countdown seconds
- PrimeCounter.javaThe slow calculation, plain Java with no Paper API
- PrimesCommand.java/primes limit: async work and the hop back
- RemindCommand.java/remind seconds message: the entity scheduler
- com/example/schedulerdemo/
- resources/
- plugin.ymlDeclares the schedulerdemo.use permission
- java/
- main/
- src/
Where this file livesscheduler-demosrcmainresourcesplugin.yml
Files in src/main/resources are copied into the jar exactly as they are. Paper looks for plugin.yml at the top of the jar.
- scheduler-demo/
- src/main/
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- AutoBroadcast.javaHelper class
- CountdownCommand.javaCommand (BasicCommand)
- CountdownTask.javaHelper class
- PrimeCounter.javaHelper class
- PrimesCommand.javaCommand (BasicCommand)
- RemindCommand.javaCommand (BasicCommand)
- SchedulerDemoPlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- resources/Files copied into the jar as they are
- plugin.ymlyou are hereTells Paper the plugin's name, version and main class
- java/com/example/schedulerdemo/Package com.example.schedulerdemo
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
1name: SchedulerDemo2version: '1.0.0'3main: com.example.schedulerdemo.SchedulerDemoPlugin4api-version: '26.3'5description: An auto-broadcast, a boss bar countdown, a prime counter on another thread and delayed reminders.6permissions:7 schedulerdemo.use:8 description: Lets a player use the scheduler demo commands.9 default: true- One permission shared by the three commands.
default: truemeans everyone has it. Permissions explains this part.
Download the scheduler demo as a Gradle project, run it, and try /countdown 10, /primes 5000000 and /remind 10 drink water. Then use /primes 50000000 and watch that you can still walk around while it counts.
Mistakes to avoid
- Mixing up seconds and ticks.
runTaskLater(plugin, task, 5)waits a quarter of a second, not five seconds. Multiply by 20. - Forgetting to cancel. A repeating task that never stops keeps running (and keeps holding on to its player, its boss bar and everything else it uses) until the server stops. Always know how each task ends.
- Using a player after they left. Delays are long enough for anything to happen. Check
player.isOnline(), or use the entity scheduler, which handles it for you. - Touching the world from an async task. It may work a hundred times and then crash. If you need the result in the game, hop back with
runTask. - Sleeping. Never
Thread.sleepin code the server calls. - Scheduling a repeating task in an event handler without a plan. The tip task above starts once per join. That is correct, because each player gets one, and each one cancels itself. A task started on every block break without canceling would pile up thousands of tasks.
- Doing heavy work in a task that runs every tick. A period of 1 means 20 runs per second. Keep those tasks tiny.
If a task throws an error, Paper does not crash. It prints a message like Task #12 for SchedulerDemo v1.0.0 generated an exception with a stack trace in the console. Read the trace like any other, as Null, exceptions and stack traces teaches.
Sip a potion slowly
Write a command /sip that heals the player one heart (2 health points) per second, five times in a row. It must stop early if the player leaves or dies, and must never heal above the player's maximum health.
Hint 1
You need state (how many sips are left), so use a BukkitRunnable. An anonymous one, written as new BukkitRunnable() { ... }.runTaskTimer(plugin, 20L, 20L), can have fields just like a normal class.
Hint 2
The maximum health is player.getAttribute(Attribute.MAX_HEALTH). It can be null in theory, so keep a fallback of 20.0. Use Math.min(limit, current + 2.0) so the health never goes over the limit.
Show the solution
The counter sipsLeft lives in the anonymous class, so it survives between runs. The task cancels itself when the player is gone or the sips are used up, and Math.min keeps the health at or below the maximum.
Where this file livesscheduler-exercisesrcmainjavacomexampleschedulerexerciseSipCommand.java
The package com.example.schedulerexercise is the folder path com/example/schedulerexercise inside src/main/java: every dot in the package name is one folder. IntelliJ creates these folders for you when you make a new package.
- scheduler-exercise/
- src/main/
- java/com/example/schedulerexercise/Package com.example.schedulerexercise
- SchedulerExercisePlugin.javaMain class: Paper starts here (named as main in plugin.yml)
- SipCommand.javayou are hereCommand (BasicCommand)
- resources/Files copied into the jar as they are
- plugin.ymlTells Paper the plugin's name, version and main class
- java/com/example/schedulerexercise/Package com.example.schedulerexercise
- build.gradle.ktsThe build recipe: Paper 26.3 API, Java 25, how the jar is made
- gradle.propertiesVersion numbers used by the build
- gradlew.batRuns Gradle on Windows without installing it
- settings.gradle.ktsThe project's name
- src/main/
Gray files come with the project template; you rarely edit them. Open the whole project in the Compile Lab, or download it from the project page.
22@Override23public void execute(CommandSourceStack source, String[] args) {24 if (!(source.getExecutor() instanceof Player player)) {25 source.getSender().sendRichMessage("<red>Only players can sip.");26 return;27 }28 player.sendRichMessage("<green>You take a slow sip...");29 30 new BukkitRunnable() {31 private int sipsLeft = SIPS;32 33 @Override34 public void run() {35 if (!player.isOnline() || player.isDead() || sipsLeft <= 0) {36 cancel();37 return;38 }39 AttributeInstance maxHealth = player.getAttribute(Attribute.MAX_HEALTH);40 double limit = maxHealth == null ? 20.0 : maxHealth.getValue();41 player.setHealth(Math.min(limit, player.getHealth() + HEALTH_PER_SIP));42 sipsLeft--;43 }44 }.runTaskTimer(plugin, 20L, 20L);45}- An anonymous class: a task class with no name, written right where it is used. It still extends
BukkitRunnable, socancel()works inside. - A dead player cannot be healed, so stop.
- Picks the smaller of the two numbers: either the healed value or the maximum.
- Wait one second, then run once per second.
Translate time into ticks
Without running anything, write the delay in ticks for each of these: a reminder after 45 seconds, a save every 10 minutes, and a particle every quarter of a second. Which of these three would you give a delay of 0, and why?
Hint
1 second is 20 ticks, so a quarter of a second is 20 divided by 4.
Show the solution
45 seconds is 45 * 20L = 900 ticks. Ten minutes is 10 * 60 * 20L = 12,000 ticks. A quarter of a second is 5 ticks. A particle effect is a good candidate for a delay of 0 so it starts right away, while the save is better with a long first delay so it does not run while the server is still starting.
Recap
- The scheduler runs your code later or repeatedly. Time is measured in ticks: 20 ticks per second.
runTaskruns next tick,runTaskLaterruns once after a delay,runTaskTimerrepeats with a delay and a period.- Use a lambda for short tasks, the
Consumerform for a task that cancels itself, and aBukkitRunnableclass for tasks with state. - Every repeating task needs an end: cancel it from inside, or keep its
BukkitTaskand cancel it from outside. Clean up what a task leaves behind inonDisable. - Slow work goes on an async task and the Bukkit API stays on the main thread: do the work async, then hop back with
runTask. - Never
Thread.sleepin code the server calls. - The global, entity and async schedulers are the Folia-style API, available on Paper too.
Quick quiz
How many ticks should you wait to run something after 3 seconds?
There are 20 ticks per second, so 3 seconds is 60 ticks. 3,000 would be milliseconds, which the scheduler does not use.What do the two numbers in
runTaskTimer(plugin, task, 40L, 100L)mean?The first number is the delay before the first run, the second is the period between later runs. The task keeps repeating until it is canceled.Which task is the best fit for
runTaskAsynchronously?Files are slow and touch nothing in the game, so they are safe to read on another thread. Teleporting and changing blocks use the Bukkit API and must stay on the main thread.Your async task found an answer and needs to tell the player. What do you do?
Going back to the main thread withrunTaskis the safe way to use the result. Sleeping would block whichever thread you are on and solves nothing.Why is
Thread.sleep(5000)in a command handler a bad idea?The command runs on the main thread, which also runs the game. ArunTaskLaterwaits without blocking anything.Why can
onDisablestill need to callcancel(), even though Paper cancels tasks when a plugin disables?Paper does stop the tasks. But stopping a task does not remove a boss bar it showed. Your own cleanup code does that.
Next steps
- Patterns: cooldowns, toggles and player state: use timing to limit how often players can do things.
- Threads, performance and lag: more on what is safe off the main thread.
- Folia and regionized scheduling: when you want your plugin to run on Folia too.
- Titles, action bars, boss bars and sounds: the screen effects the countdown uses.