atherhubather.hub
Back to Guides
Roblox internals
11 min read
May 11, 2026

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.

Ather
Ather
Lead developer at Atherhub. Writes about Roblox internals, Luau, script engineering, and platform security.Last updated May 11, 2026

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

Frame NMain threadParallel phase (worker threads run desynchronized actors)SyncHeartbeat, GUI updates, replicationActors process work in parallel — read-only on shared instancesWrites flushFrame N+1
A single frame on the Roblox scheduler. The parallel phase opens after main runs; actors picked up during this phase can execute on worker threads until the synchronization point closes them.

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.

luau
-- 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 to task.synchronize() first.
  • SharedTable instances 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.
luau
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.

Profile first, parallelise second
The most common Actor mistake is parallelising code that wasn't slow. Always measure with the Script Performance pane in Studio before reaching for desynchronize.

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.

luau
local module = {}
module.Results = SharedTable.new()
return module
ReturnsSharedTable
A new, empty shared table. Identity matters — if two scripts require the same module, they see the same SharedTable.

You 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:

luau
-- 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.
  • print and warn are not safe in parallel. Use task.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
Written by Ather

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.