Offloading game physics to a Web Worker (without killing your frame rate)

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.

Main Thread Input handling Game logic & AI Interpolate transforms Render (Three.js / WebGPU) runs at display refresh rate Physics Worker Read input state world.step(1/60) Write positions & rotations fixed 60 Hz timestep separate CPU core SharedArrayBuffer positions + rotations input (postMessage)
The physics worker owns the simulation; the main thread only reads positions for rendering. Input state is small enough for postMessage.

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 use importScripts.
  • Wasm in workers needs async init. Rapier's RAPIER.init() is async because it fetches and compiles the .wasm binary. Your worker's onmessage handler 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.add for 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.

Related
→ Object Pooling in JavaScript Games: Killing GC Stutter at 60 FPS → Hitscan vs Projectile: Shooting Mechanics in a Browser FPS → WebGPU in 2026: Porting a Three.js Game from WebGL to WebGPU
Why should I move game physics to a Web Worker?

A physics engine's world.step() can take 2–8 ms per frame depending on scene complexity. On the main thread that eats directly into your 16.6 ms frame budget at 60 FPS, leaving less time for rendering, input handling and game logic. Moving physics to a worker lets the simulation run on a separate CPU core so it never blocks the render loop.

What is the difference between postMessage, Transferable and SharedArrayBuffer?

postMessage uses structured cloning which copies data — slow for large buffers. Transferable objects move ownership at near-zero cost but the sender loses access. SharedArrayBuffer lets both threads read and write the same memory with no copying or ownership transfer, but requires cross-origin isolation headers (COOP and COEP).

Do I need special server headers for SharedArrayBuffer?

Yes. Your server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without these the browser won't expose SharedArrayBuffer. This can affect third-party embeds that don't set matching CORP headers.

How do I interpolate between physics steps and render frames?

Store both the current and previous physics state. Each render frame, calculate alpha = time since last physics tick / physics timestep. Lerp positions and slerp rotations between previous and current using that alpha. This produces smooth motion even when physics runs at a lower tick rate than the display refresh rate.