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.
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:
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.
task.spawn(f: (...any) -> ...any, ...: any): threadthreadprint("before")
task.spawn(function()
print("inside spawn")
end)
print("after")
--> before
--> inside spawn (synchronous — runs immediately)
--> afterThe 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).
task.defer(f: (...any) -> ...any, ...: any): threadthreadprint("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.
task.delay(seconds: number, f: (...any) -> ...any, ...: any): threadthreadlocal 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.
task.wait(seconds: number?): numbernumberlocal 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.
task.cancel(thread): ()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:
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 changedThe 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.
Common scheduler bugs
- Race conditions with destroy. Calling
Destroy()on an instance that has a pendingtask.delaycan 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 ... endwith notask.waithangs 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, usetick()deltas inside a heartbeat loop. - Yields in connections block subsequent connections. A long
task.waitinside a connection handler (in immediate-signal mode) holds up other listeners on the same signal. Either avoid it, or wrap the handler body intask.spawnto 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 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.