The Scheduler API
Scheduler wraps the platform scheduler with automatic cleanup and error handling. Use it to run code on
the main server thread, in the background, or repeatedly.
Every method returns a numeric task id you can pass to Scheduler.cancel().
Sync versus async
Sync tasks run on the main server thread. Anything touching players, worlds, entities, blocks or inventories must be sync. That is not a PixelScript rule, it is a Bukkit rule.
Async tasks run on a background thread pool. Use them for database queries, HTTP requests, file I/O and heavy computation.
Never touch game state from an async task. Read a player’s UUID, do your work off-thread, then hop back with
runSyncto apply the result.
Scheduler.runAsync(() => {
const balance = fetchBalanceFromDatabase(uuid); // safe: off-thread work
Scheduler.runSync(() => {
player.sendRichMessage(`<green>Balance: ${balance}`); // safe: back on the main thread
});
});
The methods
| Method | Thread | When |
|---|---|---|
runSync(callback) |
main | Next tick |
runAsync(callback) |
background | Immediately |
runSyncLater(callback, delayTicks) |
main | After a delay |
runAsyncLater(callback, delayTicks) |
background | After a delay |
runSyncTimer(callback, delayTicks, periodTicks) |
main | Repeatedly |
runAsyncTimer(callback, delayTicks, periodTicks) |
background | Repeatedly |
cancel(taskId) |
Cancel a scheduled task |
// Once, next tick
Scheduler.runSync(() => Bukkit.broadcast(mm('<green>Task executed!')));
// Once, five seconds from now
Scheduler.runSyncLater(() => {
const player = Bukkit.getPlayer('Notch');
if (player !== null) player.teleport(spawnLocation);
}, 100);
// Every second, starting immediately
Scheduler.runSyncTimer(() => {
Bukkit.getOnlinePlayers().forEach(updateScoreboard);
}, 0, 20);
// Every five minutes, first run after five minutes
Scheduler.runAsyncTimer(() => saveWorldData(), 20 * 60 * 5, 20 * 60 * 5);
Cancelling
const taskId = Scheduler.runSyncTimer(() => {
console.log('This repeats forever... or does it?');
}, 0, 20);
Scheduler.runSyncLater(() => {
Scheduler.cancel(taskId);
}, 200);
A timer that cancels itself needs the id in scope before the callback runs, which const handles fine
because the first invocation happens on a later tick:
function startCountdown(seconds) {
let remaining = seconds;
const taskId = Scheduler.runSyncTimer(() => {
if (remaining <= 0) {
Scheduler.cancel(taskId);
Bukkit.broadcast(mm('<red><bold>TIME IS UP!'));
return;
}
Bukkit.broadcast(mm(`<yellow>${remaining} seconds remaining...`));
remaining--;
}, 0, 20);
return taskId;
}
Tick timing
Minecraft runs at 20 ticks per second when healthy. Under lag, ticks take longer, so ticks are not a reliable clock. If you need wall-clock accuracy, record timestamps rather than counting ticks.
| Ticks | Time |
|---|---|
| 1 | 50 ms |
| 20 | 1 second |
| 100 | 5 seconds |
| 1200 | 1 minute |
| 24000 | 20 minutes (one Minecraft day) |
Naming your intervals pays off quickly:
const SECOND = 20;
const MINUTE = 20 * 60;
Scheduler.runSyncTimer(() => saveData(), 0, 5 * MINUTE);
Practical patterns
Delayed teleport that cancels on movement
const pendingTeleports = new Map();
function teleportWithDelay(player, location, seconds) {
const playerId = player.getUniqueId().toString();
if (pendingTeleports.has(playerId)) {
Scheduler.cancel(pendingTeleports.get(playerId));
}
player.sendRichMessage(`<yellow>Teleporting in ${seconds} seconds. Do not move.`);
const startLocation = player.getLocation();
const taskId = Scheduler.runSyncLater(() => {
pendingTeleports.delete(playerId);
if (player.getLocation().distance(startLocation) > 0.5) {
player.sendRichMessage('<red>Teleport cancelled, you moved.</red>');
return;
}
player.teleport(location);
player.sendRichMessage('<green>Teleported.');
}, seconds * 20);
pendingTeleports.set(playerId, taskId);
}
Spreading work over ticks
Doing everything at once on the main thread causes a spike. Batch it:
function processBatched(items, batchSize, periodTicks) {
let index = 0;
const taskId = Scheduler.runSyncTimer(() => {
const batch = items.slice(index, index + batchSize);
if (batch.length === 0) {
Scheduler.cancel(taskId);
return;
}
batch.forEach(processItem);
index += batchSize;
}, 0, periodTicks);
}
processBatched(myLargeArray, 10, 40); // 10 items every 2 seconds
Automatic cleanup
Every task scheduled through Scheduler is cancelled when the script unloads or reloads. Orphaned timers
from a previous version of your script are not a thing you have to think about.
This is the single biggest reason to use Scheduler instead of Bukkit.getScheduler() directly. Tasks you
schedule against the raw Bukkit scheduler survive reloads and will keep running against a dead script.
Error handling
Exceptions thrown inside a task are caught, logged with a script stack trace, and swallowed. A repeating task keeps repeating even if one iteration throws, so a listener that fails every tick will fill your log rather than stop.
Method aliases
For familiarity with Bukkit’s own naming, each method has an alias. They are identical.
| Alias | Canonical |
|---|---|
runTask() |
runSync() |
runTaskLater() |
runSyncLater() |
runTaskTimer() |
runSyncTimer() |
runTaskAsynchronously() |
runAsync() |
runTaskLaterAsynchronously() |
runAsyncLater() |
runTaskTimerAsynchronously() |
runAsyncTimer() |
Pick one convention per codebase.
Related
- The concurrency model for what runs where and why
- Database, whose calls are blocking and belong in
runAsync - HTTP fetch, same story