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:
- Total elements: \(N \times 3\)
- Buffer size in bytes: \(N \times 3 \times 4\) bytes (since a 32-bit float is 4 bytes).
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
- Buffer Detachment: Once an
ArrayBufferis included in the transfer array, its reference in the sending context becomes detached (itsbyteLengthbecomes 0). Any read or write operation on the sender side after transfer will fail. - Buffer Recycling (Ping-Ponging): Reallocating
new Float32Array()on every frame re-introduces garbage collection pauses. Always transfer the consumed buffer back to the worker so the same block of memory is reused continuously. - Static Array Indexing: Ensure the indexing order in the typed array matches your visual sprite array. If bodies are dynamically added or removed from the Matter.js world, assign a unique identifier or send an initial schema sync so the UI thread accurately maps offsets to entities.