Parallel Luau and Actors: Multithreading Roblox Game Code Without Corrupting Shared State

Do you know how much of your 16.67-millisecond frame budget goes to work that has nothing to do with drawing the current frame? If you ship on Roblox and have not put the MicroProfiler in front of that question, the honest answer is usually no.
Parallel Luau has matured well past the experimental stage, and the workloads it handles best are exactly the ones that used to force you into throttled, spread-across-frames workarounds. Terrain generation, batched pathfinding, and wide raycast sweeps all move off the main thread cleanly — provided you understand where the Actor boundary sits and what refuses to cross it.
That last clause is where most projects lose. The failure mode is rarely a crash; it is a race that surfaces as one desynced NPC out of forty, on one device tier, three weeks after launch.
What Parallel Luau Actually Changes
Before Actors, every Luau thread in your experience shared a single execution context and a single scheduler slot. Concurrency was cooperative — task.spawn and coroutine.resume interleaved work, but nothing ever ran at the same instant as anything else.
Parallel Luau adds real hardware parallelism through the Actor instance, which acts as an isolation container. Scripts parented under an Actor get their own Luau state, their own module registry, and the ability to execute on a worker thread during the scheduler's parallel phase.
The scheduler alternates between a serial phase and a parallel phase on every step. Your code opts into the parallel phase with task.desynchronize() or by connecting a handler with ConnectParallel, and it returns to the serial phase with task.synchronize().
Parallel Luau runs Luau on worker threads inside Actor instances. Code in the parallel phase can read the data model but cannot write to it; every mutation must return to the serial phase through task.synchronize().
That read/write asymmetry is the entire safety model. Roblox permits concurrent reads because reads cannot tear the data model, and forbids concurrent writes because arbitrating them would cost more than the parallelism is worth.
The mental model transfers if you have shipped anything on the web platform. If you have built OffscreenCanvas workers in a browser game, the message-passing discipline here is the same one, with a stricter type system around the channel.
Where The Actor Boundary Sits
An Actor is a real Instance that you parent into the hierarchy, and its scope is structural rather than logical. Any Script, LocalScript, or ModuleScript that is a descendant of an Actor belongs to that Actor's execution context, full stop.
This produces three consequences that consistently catch teams migrating existing code. They are worth stating plainly before you refactor anything:
- Scope follows the hierarchy. Moving a script under an Actor changes which Luau state it lives in, so a script that used to share upvalues with a sibling no longer does. Reparenting is a semantic change, not an organizational one.
- Each Actor gets its own module copy. A ModuleScript required from inside two different Actors is loaded twice, producing two independent tables with independent state. Singleton patterns that worked perfectly in serial code silently fork.
- Events do not cross for free. A BindableEvent fired inside one Actor does not hand a shared table to another Actor by reference. Actor:SendMessage and BindToMessage exist precisely because the boundary needs an explicit, serialized channel.
All of these reduce to the same rule: the Actor is a memory boundary, not merely a scheduling hint. Treat every crossing as a deliberate act of communication and most of the surprises disappear before they reach production.
If your codebase leans on module-level singletons and shared caches, revisit your Luau scripting patterns before you start parenting scripts under Actors. The real refactor is about ownership of state, not about threading.
What Crosses The Boundary And What Does Not
Actor:SendMessage serializes its arguments, and BindToMessage or BindToMessageParallel receives the copy on the other side. Serialization is why the type rules are narrow, and why some of your existing objects will not survive the trip intact.
| Value type | Crosses? | What actually happens |
|---|---|---|
| Numbers, strings, booleans, nil | Yes | Copied by value |
| Vector3, CFrame, Color3, other Roblox datatypes | Yes | Copied by value |
| Plain tables | Yes | Deep-copied, so the receiver mutating it does not affect the sender |
| Instance references | Yes | Passed as a reference to the same instance |
| SharedTable | Yes | Passed as a reference to genuinely shared memory |
| Functions and coroutines | No | Not serializable; the send errors |
| Metatables and OOP wrappers | Partly | The fields survive, the metatable and its methods do not |
Only serializable values cross an Actor boundary: primitives, Roblox datatypes, plain tables that are deep-copied, Instance references, and SharedTable. Functions, coroutines, and metatables do not survive.
The row that costs teams the most debugging time is the metatable row. An object built with setmetatable travels as a bare table, so the receiving Actor gets every field and loses every method.
The practical fix is to send data and reconstruct behavior. Pass a plain payload across the boundary, then re-attach the metatable on the receiving side from a module that the receiving Actor has already loaded.
Which Work Is Safe To Move Off The Main Thread
The screening question is not "is this slow?" but "does this write?" Work qualifies for the parallel phase when it is read-heavy, self-contained, and batched into units large enough to justify the dispatch cost.
| Workload | Parallel phase | Why |
|---|---|---|
| Voxel sampling and noise evaluation | Yes | Pure math over read-only inputs; only WriteVoxels is a write |
| Raycast, Blockcast, Spherecast sweeps | Yes | WorldRoot spatial queries are readable in parallel |
| Line-of-sight and threat scoring | Yes | Reads part positions, produces numbers |
| Loot table and inventory math | Yes | Pure computation once the inputs are copied in |
| Property writes, Destroy, reparenting | No | Data model mutation is serial-only |
| DataStore and HttpService calls | No | Yielding network calls belong to the serial phase |
| Anything ordered by completion time | No | Actor completion order is not deterministic |
Move work that is read-heavy, self-contained, and batched: raycast sweeps, voxel sampling, visibility checks, and cost scoring. Keep anything that writes to the data model or yields in the serial phase.
Everything in the "no" column can still benefit indirectly. Compute the answer in parallel, hand a compact result back across the boundary, and apply it in the serial phase in a single pass.
Terrain Generation Is The Right First Candidate
Terrain is the cleanest first migration because the expensive half and the writing half are already separable. Sampling noise, evaluating biome rules, and filling an occupancy array are pure computation; only Terrain:WriteVoxels touches the data model.
Split a chunk queue across a fixed pool of Actors, have each Actor produce filled material and occupancy arrays in parallel, and send them back for a serial write. A chunk that consumed 8 ms of main-thread time becomes roughly 8 ms of worker time plus a short write, and eight chunks in flight cost about what one chunk used to.
Keep the write batched. Calling WriteVoxels once per chunk in the serial phase is fine; calling it once per voxel column will hand back every millisecond you just saved, with interest.
The same shape applies to content delivery generally. If you already stage assets by player distance, these parallel producers slot in behind the queue you built for asset streaming without a new architecture.
Pathfinding Batches: Parallelize The Inputs, Not The Solver
Forty NPCs each calling Path:ComputeAsync on the same step is a main-thread stall you can feel in the frame graph. The instinct is to move ComputeAsync into a worker, and that instinct is usually wrong.
Yielding service calls belong to the serial phase, and every API member's reference page carries a thread-safety tag — Unsafe, Read Parallel, or Safe — that settles the question far more reliably than intuition does. Check the tag for each call you plan to move, every time.
What parallelizes cleanly is everything surrounding the solver. Candidate filtering, distance sorting, line-of-sight raycasts, and reachability pre-checks are all read-only work that determines which NPCs need a fresh path at all.
In practice that is the larger win. If a parallel pre-pass proves that thirty of your forty NPCs still hold a valid path, you have cut solver calls by seventy-five percent without touching the solver — which pairs directly with the request-throttling approach in Roblox NPC pathfinding.
Rule of thumb: parallelize the decision about whether to do expensive work before you try to parallelize the expensive work itself. The cheapest solver call is the one you proved you did not need.
Raycast Sweeps And The Visibility Problem
WorldRoot:Raycast, Blockcast, Spherecast, and the GetPartsInPart family are all readable in parallel, which makes wide spatial sampling the single best fit for Actors. Fog-of-war grids, cover scoring, projectile pre-checks, and sound occlusion all reduce to hundreds of independent casts against a static frame of the world.
Partition by index rather than by region. Give each Actor a stride through the cast list, so that Actor n handles every nth cast and no single Actor inherits all the long casts in one dense corner of the map.
Then apply the results serially, in a stable order. Combat depends on this more than most systems: if two hits resolve in whatever order the workers happened to finish, your damage numbers stop being reproducible, which matters as much for support tickets as it does for Roblox combat systems design.
How Shared State Actually Gets Corrupted
The data model is protected by the phase rules, so that is not where you lose. You lose in the state you deliberately chose to share, which on Roblox means SharedTable.
SharedTable is genuinely concurrent memory: it can be passed across Actors by reference, and individual reads and writes are each safe. What is not safe is the pattern you write without thinking about it, which is read, compute, then write.
Two Actors that each read a counter at 7, add one, and write 8 have quietly lost an increment. The library provides atomic primitives specifically so that pair collapses into a single operation:
- SharedTable.increment. Atomically adds a delta to a numeric key and returns the previous value. This is the correct tool for counters, spawn budgets, and quota tracking.
- SharedTable.update. Applies a transform function to a key atomically. This covers read-modify-write on anything more complex than a plain number.
- SharedTable.cloneAndFreeze. Produces an immutable snapshot that cannot be mutated by anyone. This is the right shape for configuration and lookup tables that many Actors read and nobody writes.
The discipline that scales past a handful of systems is single-writer ownership. Give every shared key exactly one Actor that writes it, let everyone else read, and reserve the atomics for the places where genuine contention is unavoidable.
SharedTable is safe for concurrent access, but a read-then-write pair is not atomic. Use SharedTable.update or SharedTable.increment so the read and the write land as a single operation.
The Phase-Boundary Cost Nobody Budgets For
task.desynchronize() and task.synchronize() look like ordinary function calls, and they are not. Each one yields the calling thread until the scheduler reaches the matching phase, so a round trip costs a phase boundary rather than a few microseconds.
Put that inside a per-item loop and you have built something measurably slower than the serial code you replaced. The pattern that works is one desynchronize, the entire batch, one synchronize, one bulk apply.
Batch size follows from the same arithmetic. Below roughly 0.1 ms of work per Actor per step, dispatch and messaging overhead dominate, and you are paying for coordination you are not actually using.
None of this has to be guesswork. The MicroProfiler reports parallel-phase occupancy per worker, and the measurement discipline in Roblox performance profiling applies here unchanged — profile the phase, not the function.
How Many Actors Should You Create?
More than the device has worker threads, and far fewer than one per trivial object. Roblox distributes Actors across the available workers, so a count that exceeds the pool lets the scheduler balance uneven work instead of stranding one long task on a single thread.
Worker counts vary by hardware, and mobile is the constraint that decides your design. A desktop client may offer six or eight usable workers where a low-end phone offers two, so tune your pool as a multiple of a small number rather than hardcoding 64.
Pooling beats per-entity allocation for most systems. Create a fixed set of Actors at startup, feed them work through SendMessage, and keep them warm rather than creating and destroying Actors as entities spawn and die.
Create more Actors than the device has worker threads so the scheduler can balance uneven work. Keep per-Actor work above roughly 0.1 ms; below that, dispatch overhead outweighs the parallelism.
One Actor per NPC remains a reasonable pattern when each NPC owns a meaningful amount of independent work, such as its own sensing, scoring, and animation state. The judgment call is per-entity work volume, not entity count.
How Do You Know Your Parallel Code Is Correct?
Parallel bugs do not reproduce on demand, which makes the usual play-it-and-see loop close to worthless. What you need is a differential test rather than an observation.
The cheapest version is shadow mode. Keep the serial implementation alive, run both against identical inputs on a fixed seed, and assert that the outputs match before you delete anything:
- Pin the seed and the inputs. Feed both implementations the same world snapshot and the same Random seed. Any divergence is then attributable to concurrency rather than to noise.
- Sort before you compare. Order results by a stable key such as entity id or grid index. Unsorted output will differ run to run even when the computation is perfectly correct.
- Test on the weakest device tier you support. A two-worker phone schedules Actors in orders a six-worker desktop rarely produces. That is exactly where latent races surface first.
- Watch the serial phase, not the wall clock. Total frame time can improve while serial-phase time gets worse, which means you moved work but added synchronization cost.
Run that harness on every change that touches an Actor, not just the first one. Deterministic replay earns its keep here for the same reason it does in a fixed timestep loop: it converts "it felt wrong once" into a reproducible failure you can actually fix.
When To Leave Work On The Main Thread
Parallel Luau is a throughput tool, and a great deal of expensive work is not throughput-bound. Anything that writes, yields, or must complete in a specific order relative to another system belongs in the serial phase.
Security logic deserves a specific note. Moving validation math into an Actor does not weaken anything, because the server remains the sole authority — but checks that must write state or reject an action within the same step are simpler and safer kept serial, as the layering in Roblox anti-exploit design assumes.
Parallel execution does not change your trust boundary. The server remains the sole authority, and checks that must write state or reject an action within the same step belong in the serial phase.
The honest test is whether the workload has a wide, independent middle. If it does not decompose into hundreds of units that neither read each other's output nor write to the world, the Actor boundary will cost you more in serialization than it returns in parallelism.
Frequently Asked Questions
Can a script inside an Actor write to the data model in parallel?
No. Property writes, instance creation, and reparenting all error in the parallel phase. Call task.synchronize() first, apply the mutation, then task.desynchronize() again if more parallel work remains.
Does each Actor get its own copy of a required ModuleScript?
Yes. Every Actor loads its own instance of a ModuleScript, so module-level tables are per-Actor state rather than shared state. Cross-Actor data belongs in a SharedTable or in an Actor:SendMessage payload.
How expensive is task.desynchronize compared to a function call?
They are not comparable. Both yield until the scheduler reaches the next matching phase, so a round trip costs a phase boundary — toggling per item instead of per batch erases the gain entirely.
Is Path:ComputeAsync safe to call in the parallel phase?
Treat it as serial unless the reference says otherwise. Check each member's thread-safety tag, then parallelize the candidate filtering and line-of-sight raycasts that feed the solver instead of the solver itself.
Why do my parallel results apply in a different order every run?
Actor completion order is not deterministic. Collect results, sort by a stable key such as entity id, then apply them in the serial phase so combat resolution and scoring stay reproducible.
Where To Start This Week
Pick the single most expensive read-only batch in your game — terrain sampling, a visibility grid, or an NPC scoring pass — and move only that behind a pooled set of Actors. Keep the serial version running beside it and diff the outputs for a week before you delete anything.
The two skills that decide whether this pays off are measurement and state ownership, and neither is specific to threading. Start with the profiling pass, then tighten your Luau scripting patterns so the boundary you are about to draw lands somewhere your code already respects.


