Offload Matter.js Physics Calculations to a Web Worker
Offloading Matter.js physics calculations to a Web Worker prevents heavy simulation logic from blocking the main thread, ensuring smooth UI interactions and consistent rendering frame rates. This guide explains how to decouple the Matter.js engine from the DOM, run the physics simulation inside a background worker, and synchronize body coordinates back to the main thread for rendering on an HTML5 Canvas.
The Architectural Separation
By default, Matter.js includes a built-in renderer
(Matter.Render) and runner (Matter.Runner)
designed to operate on the main browser thread alongside the DOM.
Because Web Workers lack DOM access, you must split the workflow:
- The Web Worker (Physics Thread): Instantiates
Matter.Engine, creates rigid bodies inMatter.Composite, and runs the update cycle viaMatter.Engine.update(). It extracts the essential transform data (positions and angles) and posts it back to the main thread. - The Main Thread (Render Thread): Captures user
inputs, forwards interaction events to the worker, receives updated body
coordinates, and renders the entities using
requestAnimationFrameon a<canvas>element or custom rendering library.
Setting Up the Physics Worker
Inside your worker file (e.g., physics.worker.js),
import Matter.js. Since you cannot use Matter.Render here,
you only need the physics modules: Engine,
Bodies, Composite, and Body.
// physics.worker.js
importScripts('https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js');
const { Engine, Bodies, Composite } = Matter;
const engine = Engine.create();
const world = engine.world;
// Add bodies to the world
const ground = Bodies.rectangle(400, 600, 810, 60, { isStatic: true, id: 1 });
const box = Bodies.rectangle(400, 200, 80, 80, { id: 2 });
Composite.add(world, [ground, box]);
const fixedDelta = 1000 / 60;
function tick() {
Engine.update(engine, fixedDelta);
// Extract minimal state for rendering
const bodies = Composite.allBodies(world).map(body => ({
id: body.id,
x: body.position.x,
y: body.position.y,
angle: body.angle
}));
// Send state to the main thread
self.postMessage({ type: 'UPDATE', bodies });
}
// Run the physics loop at 60Hz
setInterval(tick, fixedDelta);
// Handle messages from the main thread (inputs, additions)
self.onmessage = (event) => {
const { type, payload } = event.data;
if (type === 'APPLY_FORCE') {
const body = Composite.get(world, payload.id, 'body');
if (body) {
Matter.Body.applyForce(body, body.position, payload.force);
}
}
};Implementing the Main Thread Renderer
The main thread spawns the worker, caches the incoming physics
transformations, and handles painting using
requestAnimationFrame.
// main.js
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const worker = new Worker('physics.worker.js');
let renderBodies = [];
// Receive updated positions from the worker
worker.onmessage = (event) => {
if (event.data.type === 'UPDATE') {
renderBodies = event.data.bodies;
}
};
// Render loop decoupled from the physics update rate
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const body of renderBodies) {
ctx.save();
ctx.translate(body.x, body.y);
ctx.rotate(body.angle);
// Draw entity based on ID or configuration
if (body.id === 1) {
ctx.fillRect(-405, -30, 810, 60); // Ground
} else if (body.id === 2) {
ctx.fillRect(-40, -40, 80, 80); // Box
}
ctx.restore();
}
requestAnimationFrame(render);
}
requestAnimationFrame(render);
// Forward user interaction to worker
canvas.addEventListener('click', (e) => {
worker.postMessage({
type: 'APPLY_FORCE',
payload: { id: 2, force: { x: 0, y: -0.05 } }
});
});Performance and Serialization Considerations
To maximize throughput and avoid garbage collection pauses when running physics in a worker:
- Flatten Message Payloads: Avoid serializing entire
JavaScript objects if you have hundreds of bodies. Instead, use a single
flat array or a
Float32Array(e.g., index sequence of[id, x, y, angle]) and transfer theArrayBufferdirectly across threads using Transferable Objects. - Handle Frame Timing: Physics ticks run best on
fixed time steps (e.g., 60 updates per second via
setInterval), while rendering runs at the monitor refresh rate viarequestAnimationFrame. Maintain a history of previous and current transforms on the main thread if you need linear interpolation (lerping) for displays higher than 60Hz. - Keep Collision Events Local: Compute collision
events (
collisionStart,collisionActive) entirely within the worker. Only transmit collision data to the main thread if an audio effect, UI trigger, or visual particle spawn is required.