Avoid GC Stalls in Matter.js Web Workers

High-frequency communication between Web Workers and the main thread in Matter.js physics simulations often triggers aggressive garbage collection (GC) pauses, causing noticeable frame drops and stutter. By replacing standard structured-clone messaging with transferable typed arrays, ring buffers, object pooling, and SharedArrayBuffers, you can eliminate runtime allocations across threads and maintain a stable 60+ FPS physics loop.

The Problem: Allocation Overhead in postMessage

When running Matter.js inside a Web Worker, the simulation must transmit body states (positions, angles, velocities) to the main thread for rendering. A standard implementation serializes an array of JavaScript objects on every tick:

// Worker: Creates garbage every frame
postMessage({
  bodies: engine.world.bodies.map(b => ({ id: b.id, x: b.position.x, y: b.position.y, angle: b.angle }))
});

This pattern creates hundreds or thousands of temporary objects and arrays 60 to 120 times per second. Even though the structured clone algorithm handles serialization, the engine generates massive amounts of short-lived heap allocations on both sides of the worker boundary, forcing the browser's GC to run frequent, blocking sweep cycles.


Strategy 1: Flatten State into Transferable Float32Arrays

Transferable objects transfer memory ownership instantly without copying or serialization. Instead of passing structured objects, flatten all dynamic body properties into a contiguous typed array.

Define a fixed stride per body:

// Worker: Pack state into a Float32Array
const STRIDE = 4;
const bodyCount = engine.world.bodies.length;
const buffer = new Float32Array(bodyCount * STRIDE);

const bodies = engine.world.bodies;
for (let i = 0; i < bodies.length; i++) {
  const b = bodies[i];
  const offset = i * STRIDE;
  buffer[offset]     = b.id;
  buffer[offset + 1] = b.position.x;
  buffer[offset + 2] = b.position.y;
  buffer[offset + 3] = b.angle;
}

// Transfer ownership of the underlying buffer (zero-copy)
self.postMessage(buffer.buffer, [buffer.buffer]);

Once transferred, the worker loses access to the buffer, avoiding simultaneous read/write locks without memory duplication.


Strategy 2: Implement Double Buffering to Eliminate Array Reallocation

Allocating a new Float32Array every tick still creates garbage. Use a ping-pong double-buffering scheme where the main thread sends the emptied buffer back to the worker to be refilled:

  1. Worker initializes two ArrayBuffer instances: bufferA and bufferB.
  2. Worker fills bufferA and transfers it to the main thread.
  3. While the main thread reads bufferA, the worker fills bufferB.
  4. Main thread finishes rendering and transfers bufferA back to the worker.
  5. Worker reuses bufferA on the next frame.

This creates a closed loop where memory is allocated once during initialization and never collected.


Strategy 3: Use SharedArrayBuffer for True Zero-Copy Access

If your deployment environment supports cross-origin isolation (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp), use a SharedArrayBuffer. This eliminates postMessage entirely for physics synchronization.

// Setup on main thread or shared worker setup
const STRIDE = 4;
const maxBodies = 1000;
const sharedMemory = new SharedArrayBuffer(maxBodies * STRIDE * Float32Array.BYTES_PER_ELEMENT);
const sharedView = new Float32Array(sharedMemory);

// Send the reference once during worker initialization
worker.postMessage({ type: 'INIT', sharedMemory });

Inside the worker:


Strategy 4: Optimize Matter.js Internal Loops

Eliminating message garbage is ineffective if the worker generates heap churn within Matter.js itself:


Strategy 5: Filter Static and Sleeping Bodies

Static obstacles and sleeping bodies do not change coordinates every frame. Transmitting them wastes transfer time and cache locality:

  1. Send static bodies once during scene initialization.
  2. Maintain an internal bitset or dirty list inside the worker.
  3. Only write dynamic, non-sleeping bodies (body.isSleeping === false) into the transferable buffer.
  4. Include the active count at index 0 of the buffer so the renderer reads only the populated portion.