Appearance
Async and Concurrency
Written: 2026-10-14
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
Samples use --!strict annotations throughout. Each annotation is explained where it first appears, and Strict Typing teaches the full system later. The Janitor shape below previews the cleanup package taught fully in Packages.
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 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.
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. RemoteFunction callbacks should stay fast or callers pile up remote events. 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)
endA 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; its new, Add, and Destroy surface is documented in the Janitor docs.
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 ChestServiceBuild 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.
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 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 ChestServiceRestarts 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
- Push slow work out of a remote handler with spawn.
- Add a per-player debounce to a remote.
- Convert a last-item buy to atomic UpdateAsync.
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.
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.
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.