Cannon-es vs Rapier.js — Choosing a Browser Physics Engine

Both run in the browser; both handle rigid bodies. So which do you pick? A practical side-by-side comparison based on building simulations with each.

Context

For simulations that need real rigid-body physics — collision response, joints, constraints — you need a physics engine. Two realistic options for browser-based Three.js projects are cannon-es and Rapier.js.

I've used both for different simulations on this site. Here's a direct comparison.

At a Glance

Criterion cannon-es Rapier.js
Language TypeScript (JS fork of cannon.js) Rust → compiled to WASM
Bundle size ~150 KB gzip ~500 KB gzip (WASM)
Performance Good for <500 bodies Excellent, scales to 10,000+
Determinism Not guaranteed Deterministic across platforms
API ergonomics Simple, JavaScript-native Verbose (WASM bindings)
Three.js wrappers cannon-es-debugger, easy setup rapier3d-compat available
Collision shapes Sphere, Box, Cylinder, Convex hull All + Heightfield, Trimesh
Continuous collision No (tunnelling possible) Yes (CCD built in)

When to Use cannon-es

cannon-es is the right choice when:

import * as CANNON from 'cannon-es';
const world = new CANNON.World({ gravity: new CANNON.Vec3(0, -9.82, 0) });
const body = new CANNON.Body({ mass: 1, shape: new CANNON.Sphere(0.5) });
world.addBody(body);
// Sync in animation loop:
mesh.position.copy(body.position);
mesh.quaternion.copy(body.quaternion);

When to Use Rapier.js

Rapier is the right choice when:

My rule of thumb: Start with cannon-es. If you hit its limits (performance, tunnelling, trimesh collision), migrate to Rapier. The concepts transfer directly.

Hidden Gotcha: WASM Loading

Rapier's WASM module must be initialised before use. This adds async setup that can trip you up:

import RAPIER from '@dimforge/rapier3d-compat';
await RAPIER.init(); // Must await before any Rapier calls
const world = new RAPIER.World({ x: 0.0, y: -9.81, z: 0.0 });

If you call Rapier functions before the WASM is ready you get cryptic errors. Wrapping your sim initialisation in an async function solves this completely.