atherhubather.hub
Back to Guides
Roblox internals
11 min read
May 11, 2026

The Roblox Task Scheduler

Every Roblox script runs on top of a single scheduler that decides which coroutine wakes up when. The task library is the public face of that scheduler — four functions (spawn, defer, delay, wait) that look interchangeable on the surface but produce subtly different ordering. This article walks through what each one actually does, and why.

Ather
Ather
Lead developer at Atherhub. Writes about Roblox internals, Luau, script engineering, and platform security.Last updated May 11, 2026

The frame in one diagram

To understand the scheduler, you need a picture of what one frame looks like. The server ticks at roughly 60Hz; the client renders at whatever frame rate it can hit. Each tick is a sequence of phases:

Frame N (~16ms)Input + setupPreSimulation + physicsPostSimulation + heartbeatParallel phaseReplicatescheduler tickscheduler tickscheduler tick + defers flushdefers flushFrame N+1
The phases of a single Roblox server frame. The task scheduler runs three times per frame — once during input/setup, once during simulation, once after the parallel phase before send-replication.

Every task.* call places a coroutine on a queue tied to one of these phases. The differences between the four functions are about which queue and when the scheduler drains it.

task.spawn

Immediate coroutine start. The function runs synchronously up to its first yield, on the current scheduler tick.

luau
task.spawn(f: (...any) -> ...any, ...: any): thread
Returnsthread
The coroutine handle. You can task.cancel it later, but most callers ignore the return.
print("before")
task.spawn(function()
    print("inside spawn")
end)
print("after")
--> before
--> inside spawn   (synchronous — runs immediately)
--> after

The thing to remember about spawn: it doesn't wait for a frame, doesn't batch, doesn't defer. It's effectively "run this coroutine right now." If the function never yields, control returns to your caller when the function returns. If it does yield (with task.wait, :Wait(), etc.), control returns to you immediately and the coroutine resumes later.

task.defer

Run the function later in the same frame, after the current resumption finishes. Defers all flush together at well-defined points in the frame (after the heartbeat phase).

luau
task.defer(f: (...any) -> ...any, ...: any): thread
Returnsthread
The coroutine handle, the same shape as spawn.
print("before")
task.defer(function() print("deferred") end)
print("after")
--> before
--> after
--> deferred   (runs at end of resumption cycle, this frame)

The difference from spawn is sequencing. Spawn runs immediately, interleaved into your current call;defer runs after everything currently on the scheduler's active list. Two practical consequences: a chain of defers in a single block doesn't starve any of them (they all run, in submission order, at the flush), and a defer doesn't block the line that scheduled it.

task.delay

Schedule the function to run after a given duration. The duration is real wall-clock time, not frames, so on a low frame rate the function may run on the same frame as the one you called from.

luau
task.delay(seconds: number, f: (...any) -> ...any, ...: any): thread
Returnsthread
The coroutine handle. task.cancel(thread) before the timer fires aborts the run.
local t = task.delay(2, function() print("two seconds later") end)
-- Decide we don't want it anymore:
task.cancel(t)

The delay is a lower bound. The function won't run earlier than the specified time, but it can run a little later — the scheduler only checks delayed coroutines on its ticks, so the resolution is roughly one frame.

task.wait

The yielding form. Pauses the current coroutine for the given duration and returns the actual elapsed time.

luau
task.wait(seconds: number?): number
Returnsnumber
The actual elapsed wall-clock time, in seconds. Often slightly more than the requested amount.
local elapsed = task.wait(1)
print(elapsed)  --> 1.0167  (or so — never exactly 1)

task.wait() with no argument yields until the next frame. The legacy global wait() behaves almost the same but routes through an older, slower path that throttles when many wait()s are pending. Always usetask.wait; the legacy version is preserved only for compatibility.

task.cancel

Stop a coroutine that's been scheduled but hasn't finished. Useful when a delayed action depends on state that's no longer valid.

luau
task.cancel(thread): ()
luau
local cleanup = task.delay(5, function()
    workspace.TempBlock:Destroy()
end)

-- Player picked up the block themselves; don't auto-destroy
button.MouseButton1Click:Connect(function()
    task.cancel(cleanup)
end)

Cancellation is silent — no error fires inside the coroutine. If the thread was already running and yielded, the rest of its body simply never resumes.

Spawn vs defer: a practical example

The difference between spawn and defer only shows up under specific timing. Consider:

luau
local part = workspace.SpinningBox
part.AncestryChanged:Connect(function()
    print("ancestry changed")
end)

part.Parent = nil   -- This fires AncestryChanged
print("removed")

-- Output with deferred signals (Roblox default):
-- removed
-- ancestry changed

The signal connection ran in deferred mode — its handler waits for the scheduler to flush defers, which happens after the line that triggered it returns. If you wanted the handler to run immediately instead, you'd set workspace.SignalBehavior to Immediate. The spawn vs defer trade-off is the same idea, just at the coroutine level rather than the signal level.

Why deferred is the default
Immediate-mode signals could fire mid-block, which means the state of the world could change halfway through a function that thought it had exclusive access. Deferred mode is predictable: nothing else runs until your current block finishes. The cost is one frame of latency on event handling, which is usually invisible.

Common scheduler bugs

  • Race conditions with destroy. Calling Destroy() on an instance that has a pending task.delay can produce errors if the delayed function then tries to read properties. Always check the instance is still valid.
  • Tight loops without yield. while true do ... end with no task.wait hangs the script's tick. The engine eventually kills it with a "script timeout". Always yield somewhere in the loop.
  • Drift on long delays. task.delay(60, f) doesn't fire exactly 60 seconds later; it fires after at least 60 seconds, but possibly later under load. If you need precision, use tick() deltas inside a heartbeat loop.
  • Yields in connections block subsequent connections. A long task.wait inside a connection handler (in immediate-signal mode) holds up other listeners on the same signal. Either avoid it, or wrap the handler body in task.spawn to free the listener slot.

Wrap-up

Four scheduling primitives, each with a clear shape. spawn for "run now alongside"; defer for "run after this resumption cycle"; delay for "run after N seconds"; wait for "pause my current coroutine." Get those four shapes right and the rest of Roblox's async surface — signals, deferred firing, deferred property updates — falls into place from the same mental model.

Ather
Written by Ather

Ather is the lead developer behind Atherhub. He's been writing Luau and Roblox tooling for the better part of a decade, with a focus on the messy interface between game-script internals and the platforms that host them. Have feedback on this article? Drop it in the Discord.