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:
- Index 0:
id - Index 1:
position.x - Index 2:
position.y - Index 3:
angle
// 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:
- Worker initializes two
ArrayBufferinstances:bufferAandbufferB. - Worker fills
bufferAand transfers it to the main thread. - While the main thread reads
bufferA, the worker fillsbufferB. - Main thread finishes rendering and transfers
bufferAback to the worker. - Worker reuses
bufferAon 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:
- Update
sharedViewdirectly inMatter.Events.on(engine, 'afterUpdate', ...). - The main thread reads directly from
sharedViewduringrequestAnimationFrame. - Use an
Int32Arrayindex as an atomic synchronization flag viaAtomics.store()andAtomics.load()if you need to prevent tear frames (reading mid-write).
Strategy 4: Optimize Matter.js Internal Loops
Eliminating message garbage is ineffective if the worker generates heap churn within Matter.js itself:
- Avoid
{ x, y }Vector Literals: Do not callMatter.Body.setPosition(body, { x: newX, y: newY })inside update loops. Pre-allocate a single static vector object and mutate its properties:const tempVector = { x: 0, y: 0 }; function updatePosition(body, x, y) { tempVector.x = x; tempVector.y = y; Matter.Body.setPosition(body, tempVector); } - Reuse Query Arrays: Functions like
Matter.Query.pointorMatter.Query.rayaccept an output array or return newly allocated body lists. Avoid calling queries inside high-frequency collision loops unless strictly necessary. - Disable Unused Engine Features: If sleeping bodies
are not required, leave
engine.enableSleeping = falseto avoid tracking sleep state transitions, which allocate internal event payloads.
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:
- Send static bodies once during scene initialization.
- Maintain an internal bitset or dirty list inside the worker.
- Only write dynamic, non-sleeping bodies
(
body.isSleeping === false) into the transferable buffer. - Include the active count at index 0 of the buffer so the renderer reads only the populated portion.