task.spawn vs task.defer vs task.delay: When Does Each One Run?

Last updated:

Quick answer: task.spawn resumes a new thread immediately (it runs to its first yield before your next line), task.defer resumes it at the end of the current resumption cycle once your current code path is done, and task.delay resumes it after a set number of seconds and returns a thread you can cancel with task.cancel.

The difference between task.spawn, task.defer, and task.delay is when the scheduled thread resumes: task.spawn resumes it immediately, so the new thread runs to its first yield before your next line executes; task.defer resumes it at the end of the current resumption cycle, after the code path that scheduled it has finished; and task.delay resumes it after a fixed number of seconds and hands you back a thread you can cancel. All three run code on Roblox's task scheduler — they just disagree about timing, and picking the wrong one is what turns deterministic code into a race condition.

What does the task library do in Roblox?

The task library gives Luau threads precise, predictable scheduling. task.wait(n) pauses the current thread for at least n seconds and returns the actual elapsed time; task.spawn, task.defer, and task.delay start or resume other threads at defined moments; and task.cancel stops a scheduled thread before it runs. Since 2021 these are the supported replacements for the legacy spawn(), delay(), and wait() globals, which were throttled and could silently drop threads under load.

The mental model that makes the three schedulers click: every Roblox script event (input, Heartbeat, a signal firing) runs on a thread within a resumption cycle. task.spawn barges into the current moment, task.defer queues up behind the current cycle, and task.delay buys a ticket for a future one.

When does task.spawn resume the thread, and why does that reorder code?

task.spawn(fn) creates a thread and resumes it right now. The spawned function runs until its first yield (its first task.wait, its first yielding call), and only then does execution return to the line after task.spawn. That interleaving is exactly where ordering bugs come from:

local function handle(player)
	print("A", player.Name)
	task.wait(1)
	print("B", player.Name)
end

for _, player in Players:GetPlayers() do
	task.spawn(handle, player)
	print("C", player.Name)
end

The output per player is A, then C, then B one second later — never A B C. If handle touches state the loop is still building (a table being filled, a value being initialised), the spawned thread reads it half-finished. task.spawn is the right tool when each piece of work must start now and run independently; it is the wrong tool when you need the caller to finish first.

Why is task.defer the safe default?

task.defer(fn) schedules the thread to resume at the end of the current resumption cycle — after the code that called it has run to completion or yielded. Nothing interleaves in the middle of your logic, so the scheduled code always sees fully updated state.

Reach for task.defer whenever ordering matters but you still want a separate thread:

-- Breaking a feedback loop: setting the attribute re-triggers this handler.
part:GetAttributeChangedSignal("Mode"):Connect(function()
	task.defer(function()
		if part:GetAttribute("Mode") == "off" then
			part:SetAttribute("Mode", "standby") -- would recurse if run immediately
		end
	end)
end)

Immediate execution would nest the second SetAttribute inside the first handler's run; deferred, it runs after the handler returns, one level deep, no recursion. The same trick works for error recovery (restarting a loop from outside its own error path) and for code that must run after an event handler finishes mutating a table. When in doubt between spawn and defer, choose defer: it behaves like "call this a moment later" and never surprises you with mid-function execution.

How does task.delay schedule code, and how do you cancel it?

task.delay(seconds, fn) schedules the thread to resume roughly seconds in the future and returns immediately — the return value is the scheduled thread itself, which you can pass to task.cancel to stop it before it fires:

local cooldowns = {} -- [player] = scheduled thread

local function startCooldown(player, seconds)
	cooldowns[player] = task.delay(seconds, function()
		cooldowns[player] = nil -- cooldown finished
	end)
end

local function cancelCooldown(player)
	local thread = cooldowns[player]
	if thread then
		task.cancel(thread)
		cooldowns[player] = nil
	end
end

This is the standard cooldown and timed-effect pattern: schedule the "time's up" callback, keep the thread handle, and cancel it if the player leaves or the effect is replaced early. Note that task.delay(0, fn) is not the same as task.defer(fn) — delay is time-based and runs on a later scheduler step, while defer is cycle-based and runs at the end of the current one.

How do you replace legacy spawn(), delay(), and wait()?

The legacy globals are deprecated; this is the migration, one line each:

Legacy (deprecated)Modern replacementWhat actually changes
spawn(fn)task.spawn(fn)Resumes immediately and reliably; legacy spawn was throttled and could drop threads
delay(n, fn)task.delay(n, fn)More accurate timing; returns a thread you can task.cancel
wait(n)task.wait(n)Returns elapsed time; resumes on the scheduler instead of a 30 Hz throttle
wait()task.wait()Same one-frame yield, without the legacy throttle
any "run later" hacktask.defer(fn)Deterministic end-of-cycle timing instead of racing the current thread

Legacy wait() was the worst offender: throttled to roughly thirty resumptions per second and prone to resuming several frames late, so animation and cooldown code drifted. If you are migrating old scripts, replacing the three calls is usually the entire job — the surrounding code keeps working, and ordering bugs that the old throttle accidentally hid will surface, which is a good thing.

Which real patterns use task.spawn, task.defer, and task.delay?

Per-player handlers. When one thread must serve several players — iterating everyone at startup and on PlayerAdded — spawn each player's work so one slow DataStore yield does not block the rest:

local Players = game:GetService("Players")

local function onPlayerAdded(player)
	task.spawn(function()
		local profile = loadProfile(player) -- yields; must not stall the loop
		setupPlayer(player, profile)
	end)
end

for _, player in Players:GetPlayers() do
	onPlayerAdded(player)
end
Players.PlayerAdded:Connect(onPlayerAdded)

Cooldowns and timed effects. As shown above: task.delay to schedule the expiry, task.cancel to end it early. Anything with a duration (speed boost, respawn timer, round countdown) lives here.

Non-blocking loops. A monitoring loop should not stall on slow work. Snapshot the state, then hand the slow part to its own thread:

task.spawn(function()
	while true do
		task.wait(5)
		local snapshot = table.clone(sessionData)
		task.spawn(saveSnapshot, snapshot) -- slow writes do not stall the loop
	end
end)

And when a loop must not mutate a table it is iterating, task.defer the mutation until after the iteration finishes.

Common mistakes with task.spawn, task.defer, and task.delay

Learn Luau by doing

Scheduling bugs hide until timing changes, so the fastest way to internalise the three is to write code that breaks without the right one. On Luablox you can experiment in the browser through /theory, drill scheduling and error handling on /problems, and follow the structured Luablox lessons from first scripts to full gameplay systems.

Frequently asked questions

What is the difference between task.spawn and task.defer?

task.spawn resumes the new thread immediately: it runs to its first yield before your next line executes, so it can interleave with the code that scheduled it. task.defer resumes at the end of the current resumption cycle, after the calling code has finished, so it always sees fully updated state. Use spawn when work must start now and run independently; use defer when ordering matters.

Is task.delay a replacement for task.wait?

No. task.wait pauses the current thread for a duration and then continues it; task.delay schedules a different thread to start after a duration and returns immediately with that thread as a handle. If you want "continue this thread in N seconds", use task.wait; if you want "start this callback in N seconds without blocking", use task.delay and task.cancel it if you no longer need it.

Why did legacy spawn() break my ordering?

Legacy spawn() and delay() were throttled (roughly 30 resumptions per second) and could silently drop threads under load, so spawned code ran late, out of order, or not at all. task.spawn resumes immediately and reliably, which also means ordering bugs the old throttle accidentally hid now surface — read them as real races and fix them with task.defer or explicit sequencing.

Does task.delay return something I can cancel?

Yes. task.delay returns the scheduled thread, and task.cancel(thread) stops it before it runs. Store the handle (as in a cooldown table) and cancel it on cleanup — otherwise the callback fires later against instances that may already be destroyed.

What is the difference between task.delay(0) and task.defer?

task.delay(0, fn) schedules on a time basis and runs on a later scheduler step, while task.defer(fn) runs at the end of the current resumption cycle. Under load they can fire frames apart, so use task.defer when you mean "after the current code finishes" and task.delay only when you genuinely mean a point in time.

Learn core Roblox scripting and Luau programming concepts, including: task.spawn vs task.defer vs task.delay, task.defer vs task.spawn ordering, task.delay cancel roblox, replace spawn delay wait roblox, roblox task scheduler.

Related pages