Roblox Actors and Parallel Luau
For most of Roblox's history, all gameplay code ran on a single main thread. The Actor model — introduced as part of parallel Luau — changed that. This article walks through what an Actor actually is, how the parallel execution model works under the hood, and where it's worth the friction of using it.
What an Actor actually is
An Actor is a Roblox instance that defines an isolated execution context. Scripts parented under an Actor are eligible to run on a worker thread separate from the main scheduler. The key word there is eligible: just placing a script under an Actor doesn't parallelise anything. You have to opt in explicitly with task.desynchronize(), and as soon as you call task.synchronize() the code returns to the main thread for any operations that need it.
The model is cooperative, not pre-emptive. Roblox doesn't arbitrarily slice your script across cores — instead, every Actor gets a chance to run during the "parallel phase" of each frame. If you write a script that never desynchronizes, it stays on the main thread and behaves exactly like a regular script.
The execution model in one picture
The parallel phase is read-mostly. While desynchronized, your script can read any instance in the data model, but the set of writes you can do is intentionally restricted. Writes to most properties throw a runtime error from a parallel context — they have to wait until the script synchronizes back.
The minimum-viable Actor script
Here is the smallest useful parallel script. An Actor holds a LocalScript or Script that desynchronizes at the start, does CPU work, then synchronizes when it needs to write.
-- LocalScript inside an Actor instance
local actor = script.Parent
local RunService = game:GetService("RunService")
local function expensiveAnalysis(positions: { Vector3 }): number
local total = 0
for i = 1, #positions do
for j = i + 1, #positions do
total += (positions[i] - positions[j]).Magnitude
end
end
return total
end
local positions = {}
for _ = 1, 200 do
table.insert(positions, Vector3.new(math.random(), math.random(), math.random()))
end
RunService.Heartbeat:ConnectParallel(function()
task.desynchronize()
local result = expensiveAnalysis(positions)
task.synchronize()
actor:SetAttribute("LastTotal", result)
end)A few things to notice. We used :ConnectParallel instead of :Connect — the parallel variant is the signal-level opt-in. The connected function starts on the main thread for safety; we explicitly desynchronize before the heavy work and synchronize again before writing the attribute.
What you can and can't do in parallel
The simplest rule: in parallel, you can read almost anything, but you can't change most things. Some concrete examples:
- Reading
part.Position, walking the workspace hierarchy, and doing maths on instance properties — fine. - Setting
part.Position, parenting instances, destroying things, or firing most RemoteEvents — throws an error and you have totask.synchronize()first. SharedTableinstances can be written from parallel contexts safely. That's the canonical channel for actors to communicate results back to the main thread without a lot of synchronize calls.
local SharedTable = require(game:GetService("ReplicatedStorage"):WaitForChild("Shared"))
-- Shared.Results is a SharedTable
RunService.Heartbeat:ConnectParallel(function()
task.desynchronize()
local result = expensiveAnalysis(positions)
SharedTable.Results[actor.Name] = result -- safe in parallel
end)When Actors are worth it
Parallel Luau is fastest to dismiss and slowest to adopt: the friction is real. Splitting a script into actors, deciding what state lives in shared tables, and reasoning about which writes happen on which thread is genuinely more complex than the linear version. So when is it actually worth it?
- Wide, independent work. If you have N similar tasks (per-player physics ticks, AI decisions, pathfinding queries) and they don't depend on each other, you get nearly linear speedup up to the host's worker thread count.
- CPU-bound, read-mostly hot paths. Long loops doing maths over instance properties are the textbook case. The bottleneck has to be CPU on the script, not waiting on the engine.
- Predictable batch sizes. Parallelism overhead amortises better when each chunk is at least a few hundred microseconds of real work.
Conversely, anything that's mostly waiting on signals, has to write to instances often, or is already fast on the main thread, is the wrong fit.
Shared tables, in more detail
A SharedTable is a special table type that supports concurrent reads and atomic writes across actors. It looks like a normal table but is in fact a managed structure with locked internals.
local module = {}
module.Results = SharedTable.new()
return moduleSharedTableYou can index into it like a normal table, iterate with SharedTable.size and SharedTable.clone, and use SharedTable.increment for atomic counters. The trade-off is that values are copied on read and write — references aren't shared across actor boundaries, which is what makes the concurrency safe.
A worked example: 16 actors, one path query
Suppose you have a horde-style game and want to run pathfinding queries for sixteen NPCs each frame. On a single thread, doing this synchronously easily blows past your frame budget. With actors, each NPC owns an actor with a pathfinder script:
-- ServerScript inside each actor
local PathfindingService = game:GetService("PathfindingService")
local actor = script.Parent
local npc = actor.Parent
local SharedTable = require(game.ReplicatedStorage.NPCState)
task.desynchronize() -- ok to start parallel; we only read
actor:GetAttributeChangedSignal("TargetPosition"):ConnectParallel(function()
task.desynchronize()
local target = actor:GetAttribute("TargetPosition")
local path = PathfindingService:FindPathAsync(
npc.HumanoidRootPart.Position,
target
)
-- Stash the result in a shared table for the main-thread mover
SharedTable[actor.Name] = path:GetWaypoints()
end)The mover script on the main thread reads from the shared table each Heartbeat and applies movement. The pathfinder actors do the expensive work without ever touching the main thread, and a 16-NPC frame budget that used to be 5 ms can drop to under 1.5 ms on a four-core host.
Gotchas to keep in mind
- Calling a function that internally writes to an instance will still throw from a parallel context, even if your call site looks read-only. The error message names the offending property — read it carefully.
printandwarnare not safe in parallel. Usetask.synchronize()first if you need to log mid-work.- Actors are isolated. Globals defined in one actor's script don't leak into another's, which is good for safety but a common source of "why doesn't my variable exist" confusion.
- The number of actual worker threads is host-dependent. Don't assume eight cores — design so any number from one upward still produces correct results, just with less speedup.
Closing thoughts
Parallel Luau is the rare addition to Roblox that genuinely changes the engineering ceiling of what you can do on the platform. Used for the right workloads, it's a 3-8x speedup with no extra hardware. Used incorrectly, it's a source of confusing race conditions and slower frames. The two rules that matter most: profile before you parallelise, and treat shared tables as your concurrency contract.
Ather is the lead developer behind Atherhub. He's been writing Luau and Roblox tooling for the better part of a decade, with a focus on the messy interface between game-script internals and the platforms that host them. Have feedback on this article? Drop it in the Discord.