World.restoreSnapshot() leaks a fixed ~1730 bytes of WASM memory per call, even when the returned world is free()d.
The leak is independent of world size, which suggests a fixed-size allocation per restored World rather than anything proportional to the snapshot contents.
(Filed here rather than rapier.js, which is archived and points to typescript/.)
Environment
@dimforge/rapier2d-deterministic-compat 0.20.0
- Node v25.4.0, macOS 15 (darwin/arm64)
Reproduction
// node --expose-gc leak-repro.mjs [cycles] [bodies]
import RAPIER from "@dimforge/rapier2d-deterministic-compat";
const CYCLES = Number(process.argv[2] ?? 4000);
const BODIES = Number(process.argv[3] ?? 40);
await RAPIER.init();
function buildWorld() {
const world = new RAPIER.World(new RAPIER.Vector2(0, -9.81));
const ground = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(0, -1));
world.createCollider(RAPIER.ColliderDesc.cuboid(50, 1), ground);
for (let i = 0; i < BODIES; i++) {
const b = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic().setTranslation((i % 8) * 1.1, 1 + Math.floor(i / 8) * 1.1),
);
world.createCollider(RAPIER.ColliderDesc.cuboid(0.5, 0.5).setDensity(1), b);
}
for (let i = 0; i < 60; i++) world.step();
return world;
}
const sample = () => (global.gc(), global.gc(), process.memoryUsage());
const source = buildWorld();
const snapshot = source.takeSnapshot();
// Warm up so one-time allocations are not counted as growth.
for (let i = 0; i < 200; i++) RAPIER.World.restoreSnapshot(snapshot).free();
const before = sample();
for (let i = 0; i < CYCLES; i++) {
const w = RAPIER.World.restoreSnapshot(snapshot);
w.step();
w.free();
}
const after = sample();
console.log(`external: ${((after.external - before.external) / CYCLES).toFixed(1)} B/cycle`);
// Control: step + takeSnapshot on one world, no restore.
const control = buildWorld();
const cBefore = sample();
for (let i = 0; i < CYCLES; i++) { control.step(); control.takeSnapshot(); }
const cAfter = sample();
console.log(`control: ${((cAfter.external - cBefore.external) / CYCLES).toFixed(1)} B/cycle`);
Results
external is where WASM linear memory shows up in Node's accounting. 2500 cycles each:
| bodies |
snapshot size |
restoreSnapshot + free |
control (step + takeSnapshot) |
| 5 |
7,947 B |
1730.2 B/cycle |
0.0 B/cycle |
| 40 |
60,114 B |
1730.2 B/cycle |
26.2 B/cycle |
| 300 |
441,562 B |
1730.2 B/cycle |
180.0 B/cycle |
The snapshot size spans 55× while the leaked amount is byte-identical, and the control path — which exercises stepping and serialization but never restores — is essentially flat. Over 4000 cycles at 40 bodies the process also grows ~48 MB RSS and ~240 B/cycle of JS heap.
Why this matters
This shows up in rollback netcode, where restoreSnapshot is called on every correction. At a realistic 1–2 rollbacks/second that is roughly 6–13 MB/hour of unbounded growth — slow, but it never plateaus, so a long session eventually dies. There is no workaround from the JS side: the returned world is freed, and nothing is retained by the caller.
Happy to test a patch or provide more instrumentation if useful.
World.restoreSnapshot()leaks a fixed ~1730 bytes of WASM memory per call, even when the returned world isfree()d.The leak is independent of world size, which suggests a fixed-size allocation per restored
Worldrather than anything proportional to the snapshot contents.(Filed here rather than
rapier.js, which is archived and points totypescript/.)Environment
@dimforge/rapier2d-deterministic-compat0.20.0Reproduction
Results
externalis where WASM linear memory shows up in Node's accounting. 2500 cycles each:restoreSnapshot+freestep+takeSnapshot)The snapshot size spans 55× while the leaked amount is byte-identical, and the control path — which exercises stepping and serialization but never restores — is essentially flat. Over 4000 cycles at 40 bodies the process also grows ~48 MB RSS and ~240 B/cycle of JS heap.
Why this matters
This shows up in rollback netcode, where
restoreSnapshotis called on every correction. At a realistic 1–2 rollbacks/second that is roughly 6–13 MB/hour of unbounded growth — slow, but it never plateaus, so a long session eventually dies. There is no workaround from the JS side: the returned world is freed, and nothing is retained by the caller.Happy to test a patch or provide more instrumentation if useful.