← Blog · June 20, 2026

Your Three.js scene leaks memory in an SPA: dispose discipline and the shared-resource trap

The home page of this site is an actual 3D room: a desk, a character hammering at the keyboard, a cat on the rug. Navigate to the blog and that scene tears down. My first teardown “worked” — except Chrome’s memory graph crept a little higher on every home → blog → home round trip. The culprit is Three.js’s sneakiest detail: removing from the scene is not the same as removing from the GPU.

scene.remove isn’t enough

scene.remove(obj) pulls the object out of the scene graph, but its geometry, material, and textures still sit in GPU memory (VRAM). GLB textures weigh megabytes each; if a new one loads every visit and the old one is never released, VRAM keeps climbing until the browser throttles the tab or drops the WebGL context entirely.

You have to release all three by hand:

export function disposeObject(root: Object3D) {
  root.traverse((child) => {
    const mesh = child as Mesh;
    if (!mesh.isMesh) return;
    mesh.geometry?.dispose();
    const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
    for (const mat of materials) {
      if (mat) disposeMaterial(mat);
    }
  });
}

The real trap: don’t dispose shared resources

My first version disposed every texture blindly. It backfired: coming back to the scene, models flashed grey and broken for a frame. The reason was that my matcap textures were module-level singletons — created once and shared across the whole scene. Disposing them forced the same Texture object to be re-uploaded to the GPU on the home → blog → home trip (churn), and it made global state fragile on top of that.

The fix: don’t release a resource you don’t own. I keep a skip-list for shared textures:

const sharedTextures = new Set<Texture>([...Object.values(matcaps), blobTexture]);

export function disposeMaterial(mat: Material) {
  for (const value of Object.values(mat)) {
    const tex = value as Texture;
    if (tex && tex.isTexture && !sharedTextures.has(tex)) tex.dispose();
  }
  mat.dispose();
}

The rule is simple but easy to miss: only whoever created a resource should dispose it. Per-object GLB textures → release them. A singleton shared by the whole scene → leave it alone.

One teardown, one place

All of this fires on route change. On the Vue side, onBeforeUnmount takes everything down in order:

onBeforeUnmount(() => {
  destroyMotion();    // cut GSAP/scroll bindings
  destroyWorld();     // proceduralObjects → disposeObject
  destroyRenderer();  // renderer.dispose()
});

Everything you bound — the animation loop, the scroll listeners, the renderer — has to come down in the same place with the same discipline. That’s the tax for keeping a 3D scene inside an SPA; pay it correctly once and the scene can open and close all day with memory flat as a board.

The three.js guide on disposing objects is explicit about this: geometry, materials, and textures must be released by hand, because three doesn’t manage their lifecycle for you.