Web Workers for Heavy Physics — Offload to a Background Thread

The browser's render loop and physics loop fight over a single main thread. Move your physics step to a Worker, pass data via transferable ArrayBuffers, and both threads run at full speed without blocking each other.

Why the Main Thread Blocks

JavaScript on the main thread is single-threaded. When a physics step takes 12 ms and the render loop needs 8 ms, you have 20 ms per frame — barely 50 FPS on a 60 Hz display. On a complex simulation with 2000 rigid bodies this is optimistic.

The fix is to separate concerns: the render thread calls requestAnimationFrame and draws; the physics thread runs Cannon-es and writes positions back. They communicate asynchronously.

Architecture: Two-Thread Pipeline

Main (render) thread
←── positions Float32Array ───
Physics Worker
Three.js scene.render()
──── 'step' message ──────────→
Cannon-es world.step(dt)

Every frame the main thread sends a step message to the worker. The worker advances the simulation, packs body positions and quaternions into a shared Float32Array, and transfers it back. The render thread reads the array and updates the Three.js mesh matrices.

Setting Up the Worker

// physics.worker.js import * as CANNON from 'https://cdn.jsdelivr.net/npm/cannon-es@0.20.0/dist/cannon-es.js'; const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -9.81, 0) }); const bodies = []; // Main thread sends 'init' with body configs, then 'step' each frame self.addEventListener('message', ({ data }) => { switch (data.type) { case 'init': initBodies(data.bodies); break; case 'step': world.fixedStep(data.dt); self.postMessage({ type: 'positions', buffer: packPositions() }, [/* transferable — see below */]); break; } }); function packPositions() { // 7 floats per body: x, y, z, qx, qy, qz, qw const buf = new Float32Array(bodies.length * 7); bodies.forEach((body, i) => { const o = i * 7; buf[o] = body.position.x; buf[o+1] = body.position.y; buf[o+2] = body.position.z; buf[o+3] = body.quaternion.x; buf[o+4] = body.quaternion.y; buf[o+5] = body.quaternion.z; buf[o+6] = body.quaternion.w; }); return buf; }

Transferable ArrayBuffers (Zero-Copy)

By default, postMessage copies the data — expensive for large buffers. Marking the underlying ArrayBuffer as a transferable tells the browser to move ownership to the other thread in constant time. The original buffer becomes empty (detached) after the transfer:

// In the worker — transfer the buffer instead of copying const buf = packPositions(); self.postMessage({ type: 'positions', buffer: buf }, [buf.buffer]); // buf.buffer is now detached in this thread — do not reuse // In main.js — receive and apply worker.addEventListener('message', ({ data }) => { if (data.type !== 'positions') return; const buf = data.buffer; meshes.forEach((mesh, i) => { const o = i * 7; mesh.position.set(buf[o], buf[o+1], buf[o+2]); mesh.quaternion.set(buf[o+3], buf[o+4], buf[o+5], buf[o+6]); mesh.matrixWorldNeedsUpdate = true; }); });

Alternative: SharedArrayBuffer + Atomics

If you prefer polling over messaging (lower latency for high-frequency updates), allocate a shared buffer both threads can read and write simultaneously. Requires Cross-Origin-Isolation headers:

// main.js — create a SAB and pass it to the worker const sab = new SharedArrayBuffer(N * 7 * 4); // Float32, 7 per body const view = new Float32Array(sab); worker.postMessage({ type: 'init', sab }); // render loop — just read view[] directly, no postMessage needed meshes.forEach((mesh, i) => { mesh.position.set(view[i*7], view[i*7+1], view[i*7+2]); });
Transferable Buffer
CORS headers: not required
Sync: message-based
Overhead: one message/frame
Good for: most simulations
SharedArrayBuffer
CORS headers: COOP + COEP required
Sync: Atomics.wait / polling
Overhead: near-zero
Good for: very high body count

When NOT to Use Workers

The render loop must stay on the main thread. requestAnimationFrame, canvas drawing, and Three.js renderer.render() are not available in Workers. Offloading physics is safe; offloading rendering requires OffscreenCanvas (limited browser support and harder to use with Three.js — not recommended at this time).

Other cases where workers are not helpful:

Browser support: Web Workers are available in all modern browsers. SharedArrayBuffer requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp response headers on your server — or the feature is disabled for security reasons since Spectre.

Related Posts

Devlog #15 covers complementary performance techniques that work alongside Workers — adaptive quality presets, lazy Three.js loading, and battery-aware throttling.