When I added rigid-body physics to my Three.js FPS prototype, the frame rate dropped from a steady 60 to a
jittery 40–50 the moment a grenade spawned a dozen debris bodies. The culprit was obvious in the profiler:
world.step() was eating 6–8 ms on the main thread, right in the middle of the render loop. The
fix was equally obvious in theory — move the simulation to a
Web Worker — but the
how has a surprising number of sharp edges. Here's the practical breakdown I wish I'd had.
Why physics blocks rendering
JavaScript runs on a single thread by default. Your requestAnimationFrame loop handles input,
runs game logic, steps the physics world, and draws the scene — all in one 16.6 ms window (at 60 FPS). A
physics engine like Rapier or
cannon-es does broad-phase
collision detection, narrow-phase contact solving, and constraint resolution inside
world.step(). With a handful of boxes that takes under a millisecond. With 50–100 active rigid
bodies and compound colliders, it can take 4–8 ms — nearly half your frame budget, before you've drawn a
single pixel.
A Web Worker runs on a separate OS thread, on a separate CPU core. If you move the physics there, the main thread gets its full 16.6 ms back for rendering and input. The physics worker steps the simulation at its own pace — usually a fixed 60 Hz timestep — and the render thread just reads the latest positions and rotations. But transferring that data between threads is where the complexity lives.
Three ways to move data between threads
You have three escalating options. Each trades simplicity for speed:
1. postMessage with structured clone (simplest, slowest)
The default. You postMessage an object (or a typed array), and the browser deep-copies it into
the receiving thread. For a scene with 200 bodies you're cloning about 6,400 bytes of positions plus 6,400
bytes of quaternions every frame — roughly 13 KB. The clone itself costs 0.3–0.5 ms per message on modern
hardware. That's tolerable, but it adds up when you're also sending input state to the worker and receiving
debug data back.
// physics-worker.js
self.onmessage = (e) => {
if (e.data.type === 'step') {
world.step();
const positions = new Float32Array(numBodies * 3);
const rotations = new Float32Array(numBodies * 4);
// fill arrays from world state...
self.postMessage({ positions, rotations });
}
};
Simple, works everywhere, no special headers needed. Fine for scenes under ~50 bodies.
2. Transferable objects (zero-copy, but one-way)
Instead of copying an ArrayBuffer, you can transfer it. Ownership moves to the
receiving thread at near-zero cost — the sender's buffer becomes zero-length and unusable. For physics
this means the worker fills a Float32Array, transfers the underlying buffer to the main
thread, and the main thread reads it, then transfers it back for the next step.
// worker sends — buffer is moved, not copied
self.postMessage(
{ positions: posBuffer, rotations: rotBuffer },
[posBuffer.buffer, rotBuffer.buffer]
);
// main thread returns the buffers for reuse
worker.postMessage(
{ type: 'return', pos: posBuffer, rot: rotBuffer },
[posBuffer.buffer, rotBuffer.buffer]
);
The transfer itself is sub-microsecond. The downside is the ping-pong ownership dance: the worker can't write to the buffer while the main thread is reading it, and vice versa. In practice this means you're always one frame behind — which is usually fine, but the code is trickier.
3. SharedArrayBuffer (true shared memory)
The fastest option. Both threads read and write the same block of memory with no copying and no ownership
transfer. You allocate a SharedArrayBuffer once, wrap it in Float32Array views
on both sides, and the physics worker writes positions directly into memory that the render thread reads
from.
// main thread — create shared buffers once
const posSAB = new SharedArrayBuffer(numBodies * 3 * 4);
const rotSAB = new SharedArrayBuffer(numBodies * 4 * 4);
const positions = new Float32Array(posSAB);
const rotations = new Float32Array(rotSAB);
worker.postMessage({ type: 'init', posSAB, rotSAB });
// physics-worker.js — write directly
self.onmessage = (e) => {
if (e.data.type === 'init') {
const positions = new Float32Array(e.data.posSAB);
const rotations = new Float32Array(e.data.rotSAB);
// simulation loop writes to these every tick
}
};
Zero overhead on the data path. The catch: SharedArrayBuffer requires
cross-origin isolation. Your server must send two headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Without them, the browser won't expose SharedArrayBuffer at all. This can also break
third-party embeds (ads, analytics iframes, payment widgets) that don't set matching
Cross-Origin-Resource-Policy headers. For a game that's usually fine — for a page that mixes
game content with ad iframes, it's a real constraint.
The interpolation trick
Here's the part most tutorials skip. If your physics runs at a fixed 60 Hz but the display refreshes at 120 Hz (or 75 Hz, or whatever the monitor does), you'll see objects teleporting between physics positions once per tick instead of moving smoothly. The fix is interpolation: store both the previous and current physics state, and blend between them on each render frame.
// per render frame
const alpha = timeSinceLastPhysicsTick / physicsDt;
for (let i = 0; i < numBodies; i++) {
mesh.position.lerpVectors(prevPos[i], currPos[i], alpha);
mesh.quaternion.slerpQuaternions(prevRot[i], currRot[i], alpha);
}
With SharedArrayBuffer the worker writes current state to one region and you keep a copy of the
previous state on the main thread. Every time you detect the physics tick counter has advanced (a single
Int32 flag in shared memory), you swap previous ← current and start interpolating toward the new
current. The visual result is buttery smooth regardless of the physics rate.
Without interpolation, even perfectly correct physics will look stuttery at high refresh rates. With it, you can actually drop the physics rate to 30 Hz for complex scenes and still get visually smooth motion — saving CPU budget for more bodies or more complex colliders.
Practical setup with Rapier
Rapier is the physics engine I reach for now — it compiles to WebAssembly, runs fast, and its API is clean. Here's the minimal worker setup:
// physics-worker.js
import RAPIER from '@dimforge/rapier3d-compat';
let world, bodies;
let positions, rotations, tickFlag;
self.onmessage = async (e) => {
if (e.data.type === 'init') {
await RAPIER.init();
world = new RAPIER.World({ x: 0, y: -9.81, z: 0 });
positions = new Float32Array(e.data.posSAB);
rotations = new Float32Array(e.data.rotSAB);
tickFlag = new Int32Array(e.data.flagSAB);
// create bodies from scene description...
setInterval(step, 1000 / 60);
}
};
function step() {
world.step();
for (let i = 0; i < bodies.length; i++) {
const t = bodies[i].translation();
const r = bodies[i].rotation();
positions[i * 3] = t.x;
positions[i * 3 + 1] = t.y;
positions[i * 3 + 2] = t.z;
rotations[i * 4] = r.x;
rotations[i * 4 + 1] = r.y;
rotations[i * 4 + 2] = r.z;
rotations[i * 4 + 3] = r.w;
}
Atomics.add(tickFlag, 0, 1); // signal new data
}
On the main thread you watch tickFlag[0] each render frame. When it changes, you know fresh
data is in the shared buffers. Copy current → previous, read new current, reset your interpolation alpha.
When to use which approach
After going through all three options across different projects, here's my rule of thumb:
- Under 50 bodies, no latency pressure: plain
postMessage. Simple, no header requirements, works on every host. The copy cost is negligible. - 50–200 bodies or mobile targets: Transferable objects. Near-zero transfer cost, no cross-origin isolation needed. The ping-pong ownership pattern adds a frame of latency but keeps the code portable.
- 200+ bodies or high-refresh-rate targets:
SharedArrayBuffer. The only option that truly eliminates transfer overhead. Requires COOP/COEP headers, so you need control over your server config — but for a dedicated game page, that's rarely a problem.
Gotchas I've hit
A few things that burned time and aren't obvious from the docs:
- Module workers aren't everywhere.
new Worker('x.js', { type: 'module' })works in Chrome and Edge but not in Firefox's current stable build (as of mid-2026 it's still behind a flag). If you need Firefox, bundle the worker or useimportScripts. - Wasm in workers needs async init. Rapier's
RAPIER.init()is async because it fetches and compiles the.wasmbinary. Your worker'sonmessagehandler should await it before creating the world. - Atomics.wait blocks the thread. Useful in the physics worker for precise timing, but
never call it on the main thread — the browser will throw. Use
Atomics.load+Atomics.addfor signaling instead. - Don't forget to warm up. The first few calls to
world.step()after Wasm compilation are slower due to JIT tiering. Step the world a few times with an empty scene during load to avoid a visible hitch when gameplay starts.
The result
Moving physics to a worker brought my prototype back to a locked 60 FPS with 80+ debris bodies flying around — the same scene that was stuttering at 40 on the main thread. The profiler showed the render thread spending under 1 ms on transform interpolation, and the physics worker comfortably finishing its tick in 5 ms with 11 ms of headroom. Two cores doing two jobs instead of one core doing both.
The pattern isn't complicated once you've built it once: allocate shared buffers, let the worker own the world, interpolate on the render side, and use a tick counter for synchronization. If you're building anything with more physics than a single bouncing ball, it's one of the highest-impact performance wins available in the browser.