---
url: /intermediate/async-and-concurrency.md
description: >-
  Spawn versus defer, timeouts, rate limiting, cleanup, and race-condition
  fixes.
---

# Async and Concurrency

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

## Learning objectives

* You can choose spawn, defer, and delay correctly.
* You can spot yielding hazards in handlers.
* You can add timeouts to waits.
* You can limit rates with debounce and token buckets.
* You can disconnect events cleanly with a Janitor shape.
* You can fix a last-item race on the server.

## Prerequisites

* [Task and Async](../roblox-fundamentals/task-and-async.md)
* [Server Authority](../roblox-fundamentals/server-authority.md)
* [State and Data](./state-and-data.md)

Samples use `--!strict` annotations throughout. Each annotation is explained where it first appears, and [Strict Typing](../strict-typing/modes.md) teaches the full system later. The Janitor shape below previews the cleanup package taught fully in [Packages](../advanced/packages.md).

## Spawn now, defer later, delay timed

The task library schedules work. `task.spawn` resumes the function immediately through the scheduler without yielding the caller. `task.defer` runs it at the end of the current resume point. `task.delay` runs it after a timed wait. The [scheduler](https://create.roblox.com/docs/en-us/scripting/scheduler) defines each timing exactly. Pick by urgency, not by habit.

Keep handlers fast. Push slow saves and broadcasts into a spawned function. Never block a remote handler on a store write. The fast-handler rule comes from [Server Authority](../roblox-fundamentals/server-authority.md).

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, itemId: string): ()
	if typeof(itemId) ~= "string" then
		return
	end
	task.spawn(function(): ()
		print(player.Name)
		print(itemId)
	end)
end)
```

Create a RemoteEvent named `BuyItem` in ReplicatedStorage. Validate first. Spawn the slow print work. Expected behavior: the handler returns at once while Output prints the buyer and the id shortly after.

**Script in ServerScriptService**

```luau
--!strict
local Players = game:GetService("Players")

task.defer(function(): ()
	local list = Players:GetPlayers()
	print(#list)
end)

task.delay(3, function(): ()
	print("tick")
end)
```

The player census defers past the current frame. The tick delays by three seconds. Expected behavior: Output prints the player count almost at once, then prints `tick` three seconds later.

## Never yield where you must not

Some callbacks must finish without yielding. DataStore `UpdateAsync` callbacks cannot yield [data stores](https://create.roblox.com/docs/en-us/cloud-services/data-stores). RemoteFunction callbacks should stay fast or callers pile up [remote events](https://create.roblox.com/docs/en-us/scripting/events/remote). Prompt triggers should decide quickly and queue slow work.

Move waits out of those callbacks. Compute the next value inline. Spawn follow-up work after returning.

**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")

local stock = DataStoreService:GetDataStore("StockV1")
local done, left = pcall(function()
	return stock:UpdateAsync("Apples", function(current: number?): number
		local owned = current or 10
		if owned <= 0 then
			return 0
		end
		return owned - 1
	end)
end)
if done then
	print(left)
end
```

A pure callback is passed. It branches and subtracts. It waits for nothing. Expected behavior: Output prints the remaining apple count and concurrent buyers each decrement once.

## Time out every wait

Bound every wait with a timeout. An infinite `WaitForChild` hangs when an artist renames a part. A looped `wait` drifts. Pass a timeout and handle the miss.

Prefer event connections over polling. Poll only when no event exists. Cap retries and log the miss.

**LocalScript in StarterPlayerScripts**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem", 5) :: RemoteEvent?
if basket == nil then
	return
end
basket:FireServer("Apple", 1)
```

The wait lasts up to five seconds. A miss returns silently. Expected behavior: the client fires once when the remote exists and does nothing when the name is wrong.

## Debounce simple spam

Debounce actions one player can spam. Store a timestamp per player. Reject early fires. Clear the entry on leave so the map never leaks.

Debounce fits single-shot actions like opening a chest. Bursty but fair use like drinking three potions quickly needs a token bucket instead.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
local lastBuy: { [number]: number } = {}

basket.OnServerEvent:Connect(function(player: Player, itemId: unknown): ()
	if typeof(itemId) ~= "string" then
		return
	end
	local now = os.clock()
	local ready = lastBuy[player.UserId] or 0
	if now - ready < 2 then
		return
	end
	lastBuy[player.UserId] = now
	print(player.Name)
end)

Players.PlayerRemoving:Connect(function(player: Player): ()
	lastBuy[player.UserId] = nil
end)
```

Create a RemoteEvent named `BuyItem` in ReplicatedStorage. Check type first, then the two-second window. Record the new timestamp only on pass. Expected behavior: each player prints at most once per two seconds.

## Limit bursts with a token bucket

Each player gets a small token pool. Each action spends one token. Tokens refill over time up to a cap. Steady users pass. Flooders run dry.

Two numbers tune the bucket. Capacity sets the burst size. Refill sets the sustained rate. Tokens plus a timestamp store per player.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

local basket = ReplicatedStorage:WaitForChild("DrinkSignal") :: RemoteEvent
local tokens: { [number]: number } = {}
local stamped: { [number]: number } = {}
local capacity: number = 3
local refillPerSecond: number = 1

local function allow(player: Player): boolean
	local now = os.clock()
	local held = tokens[player.UserId]
	if held == nil then
		held = capacity
	end
	local last = stamped[player.UserId] or now
	local gain = math.floor(now - last)
	if gain > 0 then
		held = math.min(capacity, held + gain * refillPerSecond)
	end
	stamped[player.UserId] = now
	if held < 1 then
		tokens[player.UserId] = held
		return false
	end
	tokens[player.UserId] = held - 1
	return true
end

basket.OnServerEvent:Connect(function(player: Player): ()
	if not allow(player) then
		return
	end
	print(player.Name)
end)

Players.PlayerRemoving:Connect(function(player: Player): ()
	tokens[player.UserId] = nil
	stamped[player.UserId] = nil
end)
```

Create a RemoteEvent named `DrinkSignal` in ReplicatedStorage. Refill by whole seconds. Spend one token per drink. Clean both maps on leave. Expected behavior: a player drinks three times at once, then waits about one second per extra drink.

## Disconnect with one janitor

Track every connection in one place. Disconnect all of them when the feature stops. A tiny janitor table holds closures or connections and runs them in reverse. The Janitor package formalizes this shape from [Wally](https://github.com/UpliftGames/wally); its `new`, `Add`, and `Destroy` surface is documented in the [Janitor docs](https://docs.howmanysmall.com/Janitor/api/).

Never keep manual disconnect tables scattered across a Service. Add each connection once. Clean once.

**ModuleScript in ServerScriptService named ChestService**

```luau
--!strict
local Workspace = game:GetService("Workspace")

local ChestService = {}
local cleanups: { () -> () } = {}

local function track(fn: () -> ()): ()
	table.insert(cleanups, fn)
end

function ChestService.start(): ()
	local chest = Workspace:WaitForChild("Chest")
	local prompt = chest:WaitForChild("GivePrompt") :: ProximityPrompt
	local link = prompt.Triggered:Connect(function(player: Player): ()
		print(player.Name)
	end)
	track(function(): ()
		link:Disconnect()
	end)
end

function ChestService.stop(): ()
	local job = table.remove(cleanups)
	while job ~= nil do
		job()
		job = table.remove(cleanups)
	end
end

return ChestService
```

Build a part named `Chest` in Workspace with a ProximityPrompt named `GivePrompt`. Connect once. Store one cleanup closure. Drain the list on stop. Expected behavior: triggers print during play and stop printing after `stop` runs.

## Fix the last-item race

Two clients can buy the last sword in the same frame. A read-then-write path grants both. Only an atomic update grants one. Validate first, then decrement inside `UpdateAsync`. The atomic rule comes from [State and Data](./state-and-data.md).

Wrong example below. It checks stock, yields, then writes.

**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local stock = DataStoreService:GetDataStore("StockV1")
local basket = ReplicatedStorage:WaitForChild("BuySword") :: RemoteEvent

basket.OnServerEvent:Connect(function(player: Player): ()
	local done, saved = pcall(function()
		return stock:GetAsync("Swords")
	end)
	if not done then
		return
	end
	local left = saved or 1
	if typeof(left) ~= "number" then
		return
	end
	if left <= 0 then
		return
	end
	task.wait(1)
	local ok, _wrote = pcall(function()
		return stock:SetAsync("Swords", left - 1)
	end)
	if ok then
		print(player.Name)
	end
end)
```

It fails because both handlers read `1` before either writes `0`. Both pass the check. Both write `0`. The last sword duplicates. Fix it with the atomic form below.

**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local stock = DataStoreService:GetDataStore("StockV1")
local basket = ReplicatedStorage:WaitForChild("BuySword") :: RemoteEvent

basket.OnServerEvent:Connect(function(player: Player): ()
	local done, left = pcall(function()
		return stock:UpdateAsync("Swords", function(current: number?): number?
			local owned = current or 1
			if owned <= 0 then
				return nil
			end
			return owned - 1
		end)
	end)
	if not done then
		return
	end
	if left == nil then
		return
	end
	print(player.Name)
end)
```

Create a RemoteEvent named `BuySword` in ReplicatedStorage. Run one atomic update. A nil return cancels a sold-out write. The backend serializes the two calls. Expected behavior: the first buyer prints and drops stock to zero, while the second buyer gets nil and prints nothing.

## Common mistakes

**Mistake 1: you yield inside UpdateAsync.**

Wrong example below. It waits in the callback.

**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")

local stock = DataStoreService:GetDataStore("StockV1")
local done, _left = pcall(function()
	return stock:UpdateAsync("Swords", function(current: number?): number
		task.wait(1)
		local owned = current or 1
		return owned - 1
	end)
end)
print(done)
```

The backend forbids yielding there. The [data stores](https://create.roblox.com/docs/en-us/cloud-services/data-stores) guide states the callback has no permission to yield. Fix it by computing inline and by spawning slow work outside.

**Mistake 2: you debounce globally.**

Wrong example below. It shares one timestamp.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
local last: number = 0
basket.OnServerEvent:Connect(function(player: Player): ()
	local now = os.clock()
	if now - last < 2 then
		return
	end
	last = now
	print(player.Name)
end)
```

One fast buyer blocks everyone else. Fix it with a per-UserId map plus PlayerRemoving cleanup.

**Mistake 3: you never disconnect.**

Wrong example below. It connects on every start.

**ModuleScript in ServerScriptService named ChestService**

```luau
--!strict
local Workspace = game:GetService("Workspace")

local ChestService = {}
function ChestService.start(): ()
	local chest = Workspace:WaitForChild("Chest")
	local prompt = chest:WaitForChild("GivePrompt") :: ProximityPrompt
	prompt.Triggered:Connect(function(player: Player): ()
		print(player.Name)
	end)
end

return ChestService
```

Restarts stack duplicate handlers. Prints double and triple. Fix it by tracking each connection in a janitor list and by draining the list on stop.

## Exercises

1. Push slow work out of a remote handler with spawn.
2. Add a per-player debounce to a remote.
3. Convert a last-item buy to atomic UpdateAsync.

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

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, itemId: unknown): ()
	if typeof(itemId) ~= "string" then
		return
	end
	task.spawn(function(): ()
		print(player.Name)
		print(itemId)
	end)
end)
```

Expected behavior: the handler returns fast while Output still prints both values.
:::

::: details Solution 2
**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
local last: { [number]: number } = {}
basket.OnServerEvent:Connect(function(player: Player): ()
	local now = os.clock()
	local ready = last[player.UserId] or 0
	if now - ready < 2 then
		return
	end
	last[player.UserId] = now
	print(player.Name)
end)

Players.PlayerRemoving:Connect(function(player: Player): ()
	last[player.UserId] = nil
end)
```

Expected behavior: each player prints at most once per two seconds.
:::

::: details Solution 3
**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local stock = DataStoreService:GetDataStore("StockV1")
local basket = ReplicatedStorage:WaitForChild("BuySword") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player): ()
	local done, left = pcall(function()
		return stock:UpdateAsync("Swords", function(current: number?): number?
			local owned = current or 1
			if owned <= 0 then
				return nil
			end
			return owned - 1
		end)
	end)
	if not done then
		return
	end
	if left == nil then
		return
	end
	print(player.Name)
end)
```

Expected behavior: only the first of two simultaneous buyers prints.
:::

## Summary

Spawn fast and delay timed work. Keep atomic callbacks pure. Time out waits. Debounce spam and bucket bursts. Clean connections in one janitor. Sell the last item once with UpdateAsync. Next, map files to instances in [Rojo Project Structure](./rojo-project-structure.md).
