How to Serialize Matter.js Bodies for Web Workers
Running physics simulations in a Web Worker prevents the main thread
from stuttering, but Matter.js body objects cannot be sent directly via
postMessage due to circular references, methods, and heavy
internal properties. This article outlines how to extract and serialize
only the essential transform states—such as position and rotation—using
minimal data payloads and high-performance Float32Array
buffers with zero-copy memory transfers.
The Challenge with Matter.js Bodies
Matter.js Body objects are deeply nested structures
containing references to parent bodies, composite parts, constraint
arrays, and internal helper functions. Standard serialization tools like
JSON.stringify() throw
TypeError: Converting circular structure to JSON, and the
browser's structured clone algorithm (postMessage) will
either fail or introduce severe garbage collection pauses due to copying
unnecessary metadata.
For visual rendering, the main thread does not need collision vertices, mass matrices, or velocity vectors. It only needs the identifier, spatial coordinates, and rotation angle for each active body.
Strategy: Decouple Geometry from Dynamic State
To maximize efficiency, separate static setup data from dynamic frame data:
- Initialization: When the simulation starts, send
the static geometry (width, height, vertex lists, shape type, and a
unique
id) to the main thread once to instantiate the visual meshes or sprites. - Per-Frame Update: In the physics step loop inside the Web Worker, extract only the updated transform data for each dynamic body and transmit it to the main thread.
Implementation: Using Typed Arrays and Transferables
The most efficient serialization format in web browsers is a flat
Float32Array. Typed arrays can be transferred rather than
cloned, eliminating serialization overhead and memory duplication.
1. Worker Thread: Serialization and Transfer
Define a fixed stride for your data. For basic 2D rendering, allocate four floats per body:
- Index 0: Body ID
- Index 1: Position X
- Index 2: Position Y
- Index 3: Angle (radians)
// worker.js
import Matter from 'matter-js';
const { Engine, Bodies, Composite } = Matter;
const engine = Engine.create();
// Array of dynamic bodies
const dynamicBodies = Composite.allBodies(engine.world).filter(body => !body.isStatic);
// Stride length: [id, x, y, angle]
const STRIDE = 4;
function tick() {
Matter.Engine.update(engine, 1000 / 60);
const buffer = new Float32Array(dynamicBodies.length * STRIDE);
for (let i = 0; i < dynamicBodies.length; i++) {
const body = dynamicBodies[i];
const offset = i * STRIDE;
buffer[offset] = body.id;
buffer[offset + 1] = body.position.x;
buffer[offset + 2] = body.position.y;
buffer[offset + 3] = body.angle;
}
// Transfer the underlying ArrayBuffer with zero-copy transfer
self.postMessage(buffer, [buffer.buffer]);
requestAnimationFrame(tick);
}
tick();2. Main Thread: Deserialization and Rendering
On the main thread, read the values from the transferred
Float32Array directly and update your renderer (such as
HTML5 Canvas, PixiJS, or Three.js).
// main.js
const worker = new Worker('worker.js', { type: 'module' });
// Map of visual objects mapped by body.id
const visualObjects = new Map();
const STRIDE = 4;
worker.onmessage = (event) => {
const data = event.data; // Float32Array
const count = data.length / STRIDE;
for (let i = 0; i < count; i++) {
const offset = i * STRIDE;
const id = data[offset];
const x = data[offset + 1];
const y = data[offset + 2];
const angle = data[offset + 3];
const visual = visualObjects.get(id);
if (visual) {
visual.x = x;
visual.y = y;
visual.rotation = angle;
}
}
};Best Practices for Low-Latency Physics
- Avoid Memory Allocation in the Loop: Recreating a
Float32Arrayevery frame creates garbage collector pressure. Instead, maintain a pool of twoArrayBufferinstances and alternate between them (ping-pong buffering), transferring one buffer while writing to the other. - Filter Out Sleeping Bodies: Check
body.isSleeping. If a body is inactive, omit its state from the buffer or mark it with a skip flag to reduce loop cycles on both threads. - Synchronize Physics and Render Rates: Use a fixed time-step inside the worker for deterministic physics calculations, and interpolate positions on the main thread if render refresh rates differ from the physics update rate.