Paper Plugin Guide
File mode0

Paper essentials

Timing and tasks: the scheduler

Run code later, repeat it every few seconds, and do slow work without freezing the server.

Intermediate44 min read

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.

Ticks and scheduled tasks One second is 20 ticks of 50 milliseconds each. A task scheduled with runTaskLater and a delay of 40 ticks runs 2 seconds later. A task scheduled with runTaskTimer repeats every period. 1 second = 20 ticks, and each tick is 50 ms tick 0 0 s tick 10 0.5 s tick 20 1 s tick 30 1.5 s tick 40 2 s Task 1: runTaskLater(plugin, task, 40) waits 40 ticks (2 seconds), then runs once runs Task 2: runTaskTimer(plugin, task, 20, 20) runs runs first run after 20 ticks, then again every 20 ticks, until you cancel it
The server's heartbeat: 20 ticks per second. Scheduled tasks run inside a tick, between the other work Paper does.
TimeTicksHow to write it in code
1 tick11L
1 second2020L
5 seconds1005 * 20L
1 minute1,20060 * 20L
5 minutes6,0005 * 60 * 20L
1 hour72,00060 * 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.

NudgeListener.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. A named constant. Using it keeps the numbers below readable: 3 * TICKS_PER_SECOND is obviously "3 seconds".
  2. The scheduler needs to know which plugin owns a task, so the listener keeps a reference to the plugin. The main class passes this in.
  3. 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).
  4. 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.
  5. Three seconds is a long time. The player may have left, so check before sending. A task cannot know that on its own.
  6. Schedules a repeating task. This form of the lambda receives the task itself, so the code can cancel itself.
  7. Stops this repeating task for good. It will not run again. Without this line the tip would repeat until the server stops.
  8. The first number is the delay before the first run (5 seconds). The second is the period, the time between runs (10 seconds).
SchedulerFirstPlugin.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. Hands the listener the plugin (this) it needs for scheduling.
  2. The same method, called straight from onEnable. 100L ticks is 5 seconds.
  3. 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:

Glad you stayed. Have a look around!
Tip: mine some ore or defeat a mob to earn your first experience level.
Server console
[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.

Delay and period: when your task runs (each box is one run) runTaskLater(plugin, task, 60) wait 60 ticks runTaskTimer(plugin, task, 0, 20) runTaskTimer(plugin, task, 40, 20) delay: 40 ticks period: 20 ticks 0 20 40 60 80 100 0 s 1 s 2 s 3 s 4 s 5 s ticks
Three tasks on the same timeline. A delay of 0 runs right away; a delay of 40 waits two seconds before the first run.

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.

MethodWhat 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.

  1. A lambda: () -> player.sendRichMessage("Hi"). Best for short, one-off code, like the delayed welcome above.
  2. A lambda with a task parameter: task -> { ... task.cancel(); }. Java calls this form a Consumer: 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 the BukkitTask for later.
  3. A class that implements Runnable or extends BukkitRunnable. 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:

AutoBroadcast.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. A Runnable is anything with a run() method and no parameters. The scheduler accepts it as a task.
  2. 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.
  3. Makes a private, unchangeable copy, so nobody can change the list while the task is using it.
  4. Nobody to hear the message? Skip this round and save the effort.
  5. Turns the MiniMessage text into a colored chat message and sends it to everyone, including the console.
  6. 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.

SchedulerDemoPlugin.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. Starts the repeating task and returns a BukkitTask. The next section explains why the result is saved in a field.
  2. One object that lives as long as the task does, so its nextIndex keeps counting.
  3. Delay of one minute, period of five minutes.
Tip: /countdown 10 starts a boss bar countdown.
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:

CountdownTask.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. The task inherits runTaskTimer and cancel from BukkitRunnable, and must provide run() itself.
  2. A shared list of all countdowns that are currently active. It exists so the plugin can stop them all when it shuts down.
  3. The state: how many seconds are left. A lambda could not keep this as easily.
  4. The constructor is private, so the only way to start a countdown is the start method below, which sets everything up in the right order.
  5. Creates the task, shows the boss bar and schedules the task with runTaskTimer(plugin, 0L, TICKS_PER_SECOND): no delay, then once per second.
  6. On a BukkitRunnable you do not pass the task, because the object itself is the task: just the plugin, the delay and the period.
  7. Cancel removes the task from RUNNING. Looping over a list while removing from it crashes Java, so the loop goes over a copy.
  8. The player left mid-countdown. Cancel, or the task would keep running for nobody.
  9. The finish line: show "Go!", play a sound and cancel. Nothing else will run after cancel(), and the return makes sure the rest of this method is skipped too.
  10. 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.
  11. Subtracts one from the seconds left, ready for the next run.
  12. 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:

CountdownCommand.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. Only a player has a screen, so only a player gets a boss bar. The console sees an error message instead.
  2. Turns the text the player typed into a number. If it is not a number, Java throws a NumberFormatException, and the catch shows a friendly message instead of crashing.
  3. Never trust input. A countdown of 2 billion seconds would just waste memory.
  4. Everything is valid, so start the countdown.
Starting in 7s
Starting in 3s
Go!

Canceling tasks

A repeating task runs until something stops it. There are four ways to stop one:

  • From inside: call task.cancel() in the Consumer form, or cancel() in a BukkitRunnable. This is the cleanest way, and the one the examples use.
  • From outside, with a saved task: the Runnable and BukkitRunnable forms of runTaskTimer return a BukkitTask, a handle for the running task. Save it in a field and call cancel() 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.
SchedulerDemoPlugin.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. If onEnable failed early, the field was never filled in, and calling cancel() on nothing would crash.
  2. Stops the repeating broadcast through the saved BukkitTask.
  3. 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.

Main thread and async thread The main thread runs the game. Slow work like a database query goes on an async thread so the game does not freeze. The result hops back to the main thread with runTask before you touch the game. Main thread (the game) Player types /stats Game keeps ticking, no lag Use the result player.sendMessage Async thread (background) Slow work database query or web request runTaskAsynchronously runTask hop back Never touch worlds, blocks or players from the async lane. Only the main thread may change the game. Do the slow part async, then hop back.
The main thread has a strict budget of 50 ms per tick. Slow work on it makes every player lag.

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 threadNot safe: do it on the main thread
Math, loops and your own objects that nobody else touchesChanging blocks, entities, worlds or inventories
Reading and writing your own filesTeleporting a player, changing their health or items
Database queries and web requestsLooking around the world (getBlockAt, nearby entities)
Plain text, numbers and copies of dataAlmost 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.

Slow work off the main thread, then back with the answer Main thread (the server tick loop) command runs runTask: send result Another thread (your async task) slow work: count the primes runTaskAsynchronously hop back
The async round trip: the command starts the work, another thread does it, and runTask brings the answer home.
PrimesCommand.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. The command still runs on the main thread here. Anything you need from the game, like the sender, is collected now.
  2. A limit variable that is assigned once is allowed inside the lambda below, because Java lets lambdas use variables that never change.
  3. From here to the closing brace the code runs on another thread. The server keeps ticking.
  4. A very precise stopwatch, in nanoseconds (billionths of a second). It is plain Java, not Paper API, so it is fine anywhere.
  5. 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.
  6. The hop back. This second lambda runs on the main thread, where it is safe to message a player.
  7. Fills the <count>, <limit> and <time> tags in the message with the real values.
Counting primes up to 5000000 in the background...
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.

JavaHas a mistake
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.

SchedulerHow to get itUse it for
Global regiongetServer().getGlobalRegionScheduler()Server-wide things with no particular location, such as the auto-broadcast.
Entityentity.getScheduler() (works for players)Anything about one entity. Runs where that entity is, and stops by itself if the entity is removed.
AsyncgetServer().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:

RemindCommand.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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);
  1. Each player has its own scheduler. Tasks on it are tied to that player.
  2. Runs the task once after a delay. Compare it to runTaskLater: the order of arguments is similar, but the delay is the last argument.
  3. The task receives a ScheduledTask. This one ignores it, since a single run has nothing to cancel.
  4. The "retired" callback: code to run instead if the player is gone before the task is due. null means do nothing.
  5. 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:

Java
1getServer().getGlobalRegionScheduler().runAtFixedRate(this, task -> {2    getServer().sendRichMessage("<gray>The server is still running.");3}, 20L, 6000L);
Java
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
        • resources/
          • plugin.ymlDeclares the schedulerdemo.use permission
plugin.ymlCompiles on Paper 26.3Compile Lab
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
    • 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

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
  1. One permission shared by the three commands. default: true means 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.sleep in 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.

Try it

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.

SipCommand.javaCompiles on Paper 26.3Compile Lab
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
    • 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

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}
  1. An anonymous class: a task class with no name, written right where it is used. It still extends BukkitRunnable, so cancel() works inside.
  2. A dead player cannot be healed, so stop.
  3. Picks the smaller of the two numbers: either the healed value or the maximum.
  4. Wait one second, then run once per second.
Try it

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.
  • runTask runs next tick, runTaskLater runs once after a delay, runTaskTimer repeats with a delay and a period.
  • Use a lambda for short tasks, the Consumer form for a task that cancels itself, and a BukkitRunnable class for tasks with state.
  • Every repeating task needs an end: cancel it from inside, or keep its BukkitTask and cancel it from outside. Clean up what a task leaves behind in onDisable.
  • 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.sleep in code the server calls.
  • The global, entity and async schedulers are the Folia-style API, available on Paper too.

Quick quiz

  1. How many ticks should you wait to run something after 3 seconds?

  2. What do the two numbers in runTaskTimer(plugin, task, 40L, 100L) mean?

  3. Which task is the best fit for runTaskAsynchronously?

  4. Your async task found an answer and needs to tell the player. What do you do?

  5. Why is Thread.sleep(5000) in a command handler a bad idea?

  6. Why can onDisable still need to call cancel(), even though Paper cancels tasks when a plugin disables?

Next steps