Appearance
Task and Async
Written: 2026-10-14
Learning objectives
- You can use
task.wait,task.spawn,task.delay, andtask.defer. - You can avoid legacy
wait,spawn, anddelay. - You can run per-frame work with Heartbeat and PreRender.
- You can yield safely with timeouts.
- You can spot race conditions in small workflows.
Prerequisites
The task library
The scheduler gives you the task library. It schedules your threads through the engine. Use it for every wait and background call.
task.wait yields your thread for a duration, then resumes on the next Heartbeat step. It returns the actual time waited. task.spawn starts a function immediately through the scheduler. task.delay starts a function after a duration. task.defer starts a function at the end of the current resume point.
Never use legacy global wait, spawn, or delay. The scheduler calls them less optimized and less configurable. Migrate explicitly: wait(n) to task.wait(n), spawn(f) to task.defer(f) (or task.delay(0, f)), and delay(n, f) to task.delay(n, f). Note task.spawn resumes immediately while task.defer runs at the end of the current resume point, so they are not interchangeable.
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
local waited = task.wait(1)
print(waited)
task.spawn(function(): ()
local note = Instance.new("StringValue")
note.Name = "Spawned"
note.Value = "Hello"
note.Parent = Workspace
end)Wait one second. Print the measured time. Spawn background work that builds one object parent-last. Expected behavior: Output prints a number near 1, then a Spawned value appears in Workspace.
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
task.delay(2, function(scheduled: number): ()
local elapsed = os.clock() - scheduled
local note = Instance.new("NumberValue")
note.Name = "Delayed"
note.Value = elapsed
note.Parent = Workspace
end, os.clock())
task.defer(function(): ()
print("Deferred")
end)
print("Immediate")task.delay runs after two seconds and records the real gap. task.defer runs after the current line, so Immediate prints first. Expected behavior: Output prints Immediate, then Deferred, then a Delayed value appears near two seconds later.
Heartbeat and PreRender basics
RunService drives frame-by-frame work. Heartbeat fires on server and client after physics simulation. PreRender fires on the client before each frame render, and new work should prefer it over the older RenderStepped.
Use Heartbeat for game logic that tracks time or physics. PreRender suits camera and visual-only updates on the client. Do not put heavy loops on either one. Cache your references outside the handler, as the Instance reference advises for per-frame code.
Script in ServerScriptService
luau
--!strict
local RunService = game:GetService("RunService")
local ticks = 0
local connection: RBXScriptConnection? = nil
connection = RunService.Heartbeat:Connect(function(delta: number): ()
ticks += 1
print(delta)
if ticks >= 3 and connection then
connection:Disconnect()
end
end)Count three beats. Connect once. Print each frame duration. Disconnect after three runs. Expected behavior: Output prints three small durations, then stops.
LocalScript in StarterPlayerScripts
luau
--!strict
local RunService = game:GetService("RunService")
local frames = 0
local connection: RBXScriptConnection? = nil
connection = RunService.PreRender:Connect(function(delta: number): ()
frames += 1
print(delta)
if frames >= 3 and connection then
connection:Disconnect()
end
end)Connect to PreRender on the client. Print three frame times. Disconnect. Expected behavior: Output prints three small durations, then stops.
Yield with timeouts
Some waits never end. A missing child never arrives. An event never fires. Add a timeout so your thread always continues.
WaitForChild accepts a timeout in seconds. Event Wait does not, so you race it against task.delay with a flag. The scheduler pattern keeps both sides in task calls.
LocalScript in StarterPlayerScripts
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local found = ReplicatedStorage:WaitForChild("ShopConfig", 5)
if found then
print(found.Name)
else
print("Missing")
endCreate a ModuleScript named ShopConfig in ReplicatedStorage for the success path. Wait up to five seconds. Branch on the result. Expected behavior: Output prints ShopConfig when it replicates in time, else it prints Missing.
Race-condition intuition
Two threads can touch the same value in either order. The winner depends on timing. Deferred events make this more likely, because handlers queue instead of running at once.
Picture two coin pickups granting one reward. Both read the same balance, add one coin, then write back. One write can overwrite the other. Guard shared state with one owner thread, or serialize updates through one handler.
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
local counter = Instance.new("IntValue")
counter.Name = "ClaimCount"
counter.Value = 0
counter.Parent = Workspace
local function claim(): ()
local seen = counter.Value
task.wait()
counter.Value = seen + 1
end
task.spawn(claim)
task.spawn(claim)
task.wait(1)
print(counter.Value)Two increments race with a yield between the read and the write. Both threads read 0 before either writes back, so one increment is lost. Expected behavior: Output prints 1, not 2. Without the task.wait() in the middle both threads would run to completion with no yield and the answer would stay 2, because cooperative threads never interrupt each other mid-step. A race needs a yield inside read-modify-write, and the fix is one owner thread or one serialized handler for shared state.
Small async workflows
Combine waits, delays, and events into short pipelines. Keep each step visible. Disconnect when you finish.
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
local gate = Instance.new("Part")
gate.Name = "AsyncGate"
gate.Size = Vector3.new(6, 4, 1)
gate.Anchored = true
gate.Position = Vector3.new(0, 3, 20)
gate.Parent = Workspace
task.delay(2, function(): ()
gate.Transparency = 0.5
print("Opened")
end)
gate.Touched:Once(function(hit: BasePart): ()
print(hit.Name)
end)Two async actions run here. A delayed call opens the gate visually after two seconds. A Once handler greets the first toucher. Expected behavior: Output prints Opened after two seconds, and prints the first toucher name on contact.
Common mistakes
Mistake 1: calling legacy wait.
Wrong example below. It uses the old global.
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
wait(1)
local box = Instance.new("Part")
box.Name = "LegacyBox"
box.Size = Vector3.new(2, 2, 2)
box.Anchored = true
box.Parent = WorkspaceIt works but it schedules poorly. The scheduler tells you to use task.wait instead. Fix it by swapping one call.
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
task.wait(1)
local box = Instance.new("Part")
box.Name = "FixedBox"
box.Size = Vector3.new(2, 2, 2)
box.Anchored = true
box.Parent = WorkspaceMistake 2: calling FindFirstChild every frame.
Wrong example below. It searches inside a hot loop.
Script in ServerScriptService
luau
--!strict
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
RunService.Heartbeat:Connect(function(): ()
local plate = Workspace:FindFirstChild("WelcomeMat")
if plate then
print(plate.Name)
end
end)It wastes time on every frame. The Instance reference warns against this in Heartbeat handlers. Fix it by caching the reference once.
Script in ServerScriptService
luau
--!strict
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local plate = Workspace:FindFirstChild("WelcomeMat")
RunService.Heartbeat:Connect(function(): ()
if plate then
print(plate.Name)
end
end)Mistake 3: waiting forever for an event.
Wrong example below. It yields with no escape.
LocalScript in StarterPlayerScripts
luau
--!strict
local Workspace = game:GetService("Workspace")
local pad = Workspace:WaitForChild("ForeverPad")
local hit = pad.Touched:Wait()
print(hit.Name)It hangs when nobody touches the pad. Fix it with a timeout flag and task.delay, or handle the no-touch case explicitly.
Exercises
- Wait one second with
task.waitand print the measured time. - Run one delayed and one deferred print and observe the order.
- Build a three-beat Heartbeat counter that disconnects itself.
Solution 1
Script in ServerScriptService
luau
--!strict
local measured = task.wait(1)
print(measured)Expected behavior: Output prints a number close to 1.
Solution 2
Script in ServerScriptService
luau
--!strict
task.delay(1, function(): ()
print("Delayed")
end)
task.defer(function(): ()
print("Deferred")
end)
print("Immediate")Expected behavior: Output prints Immediate, then Deferred, then Delayed after one second.
Solution 3
Script in ServerScriptService
luau
--!strict
local RunService = game:GetService("RunService")
local count = 0
local connection: RBXScriptConnection? = nil
connection = RunService.Heartbeat:Connect(function(): ()
count += 1
print(count)
if count >= 3 and connection then
connection:Disconnect()
end
end)Expected behavior: Output prints 1, 2, 3, then stops.
Summary
Schedule with task. Tick with Heartbeat and PreRender. Time out every open wait. Serialize shared writes. Next, handle join and spawn in Players and Characters.