Transfer ArrayBuffers with Matter.js in Web Workers

Running physics simulations like Matter.js inside a dedicated Web Worker offloads heavy computations from the UI thread, preventing frame drops and input lag. However, sending physics states via standard structured cloning introduces serialization overhead and triggers frequent garbage collection. This article demonstrates how to pack Matter.js body transformations (positions and rotations) into typed arrays and pass the underlying ArrayBuffer as a Transferable Object between a Web Worker and the UI thread with zero-copy performance.

1. Structure the Binary Layout

Instead of passing an array of JavaScript objects, flatten the state of each body into a contiguous typed array (Float32Array). For a standard 2D physics simulation, each body requires three values: X coordinate, Y coordinate, and rotation angle.

If you are tracking \(N\) bodies:

2. Implement the Web Worker

Inside the Web Worker, initialize the Matter.js engine and runner. At each tick, extract the positional data, populate the Float32Array, and invoke postMessage by specifying the underlying ArrayBuffer in the transfer list (the second argument of postMessage).

// worker.js
import Matter from 'matter-js';

const { Engine, World, Bodies } = Matter;

const engine = Engine.create();
const bodies = [];

// Populate the world with bodies
for (let i = 0; i < 1000; i++) {
  const body = Bodies.circle(Math.random() * 800, Math.random() * 600, 10);
  bodies.push(body);
  World.add(engine.world, body);
}

// Fixed timestep loop
const TIME_STEP = 1000 / 60;
let sharedBuffer = new ArrayBuffer(bodies.length * 3 * Float32Array.BYTES_PER_ELEMENT);

function step() {
  Engine.update(engine, TIME_STEP);

  // View the buffer as 32-bit floats
  const stateView = new Float32Array(sharedBuffer);

  for (let i = 0; i < bodies.length; i++) {
    const body = bodies[i];
    const offset = i * 3;
    stateView[offset] = body.position.x;
    stateView[offset + 1] = body.position.y;
    stateView[offset + 2] = body.angle;
  }

  // Transfer ownership of the buffer to the main thread
  self.postMessage({ buffer: sharedBuffer }, [sharedBuffer]);

  // sharedBuffer is now detached and unusable until returned by the main thread
}

// Receive the recycled buffer back from the main thread
self.onmessage = (event) => {
  if (event.data.buffer) {
    sharedBuffer = event.data.buffer;
    requestAnimationFrame(step);
  }
};

// Start the loop
step();

3. Handle the Transferred Buffer in the UI Thread

On the UI thread, listen for messages from the worker. Wrap the incoming transferred ArrayBuffer in a Float32Array view to read the updated positions and angles for rendering, then transfer the buffer back to the worker to eliminate memory reallocations.

// main.js
const worker = new Worker('./worker.js', { type: 'module' });

worker.onmessage = (event) => {
  const buffer = event.data.buffer;
  const stateView = new Float32Array(buffer);
  const totalBodies = stateView.length / 3;

  for (let i = 0; i < totalBodies; i++) {
    const offset = i * 3;
    const x = stateView[offset];
    const y = stateView[offset + 1];
    const angle = stateView[offset + 2];

    // Render logic (e.g., updating Canvas, WebGL, or DOM elements)
    renderBody(i, x, y, angle);
  }

  // Transfer the buffer back to the worker to prevent garbage collection
  worker.postMessage({ buffer }, [buffer]);
};

function renderBody(index, x, y, angle) {
  // Update your visual representation here
}

Key Considerations