---
url: /roblox-fundamentals/task-and-async.md
description: Schedule work with the task library, yield safely, and avoid race conditions.
---

# Task and Async

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You can use `task.wait`, `task.spawn`, `task.delay`, and `task.defer`.
* You can avoid legacy `wait`, `spawn`, and `delay`.
* 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

* [Services and Events](./services-and-events.md)
* [Functions Advanced](../luau-basics/functions-advanced.md)
* [Errors and Defensive Code](../luau-basics/errors-and-defensive-code.md)

## The task library

The [scheduler](https://create.roblox.com/docs/en-us/scripting/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](https://create.roblox.com/docs/en-us/scripting/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](https://create.roblox.com/docs/en-us/reference/engine/classes/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](https://create.roblox.com/docs/en-us/reference/engine/classes/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](https://create.roblox.com/docs/en-us/reference/engine/classes/Instance#WaitForChild) accepts a timeout in seconds. Event Wait does not, so you race it against `task.delay` with a flag. The [scheduler](https://create.roblox.com/docs/en-us/scripting/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")
end
```

Create 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](https://create.roblox.com/docs/en-us/scripting/events/deferred) 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 = Workspace
```

It works but it schedules poorly. The [scheduler](https://create.roblox.com/docs/en-us/scripting/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 = Workspace
```

**Mistake 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](https://create.roblox.com/docs/en-us/reference/engine/classes/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

1. Wait one second with `task.wait` and print the measured time.
2. Run one delayed and one deferred print and observe the order.
3. Build a three-beat Heartbeat counter that disconnects itself.

::: details Solution 1
**Script in ServerScriptService**

```luau
--!strict
local measured = task.wait(1)
print(measured)
```

Expected behavior: Output prints a number close to `1`.
:::

::: details 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.
:::

::: details 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](./players-and-characters.md).
