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:

  1. 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.
  2. 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:

// 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