DEV Community

MSakai
MSakai

Posted on

Your Three.js scene is leaking, and removing the mesh didn't help

The symptom is unmistakable once you've seen it: navigate between two views a dozen times and the tab climbs past a gigabyte, or the frame rate degrades and never recovers.

scene.remove(mesh)
mesh = null
Enter fullscreen mode Exit fullscreen mode

Both lines run. Memory doesn't come back.

What remove does and doesn't do

scene.remove() detaches the object from the scene graph. That's all it does. JavaScript will happily collect the Mesh object itself.

But a Mesh is a thin wrapper. The actual data — vertex buffers, index buffers, texture uploads, compiled shader programs — lives in WebGL, on the GPU, allocated through the rendering context. The garbage collector has no visibility into that at all. Those resources are freed only when you explicitly ask.

What actually needs disposing

Three things, and each one is separate:

function disposeMesh(mesh) {
  mesh.geometry.dispose()

  const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
  for (const material of materials) {
    for (const key of Object.keys(material)) {
      const value = material[key]
      if (value && value.isTexture) value.dispose()
    }
    material.dispose()
  }
}
Enter fullscreen mode Exit fullscreen mode

The texture loop matters more than it looks. A single MeshStandardMaterial can hold map, normalMap, roughnessMap, metalnessMap, aoMap, emissiveMap, displacementMap — seven separate uploads, none of which material.dispose() touches.

Doing it for a whole subtree

function disposeHierarchy(root) {
  root.traverse((obj) => {
    if (obj.isMesh || obj.isPoints || obj.isLine) disposeMesh(obj)
  })
  root.parent?.remove(root)
}
Enter fullscreen mode Exit fullscreen mode

traverse visits the node and all descendants, so this handles a loaded glTF scene in one call.

The things that are easy to forget

Render targets. WebGLRenderTarget holds a framebuffer and at least one texture. Post-processing chains often hold several.

renderTarget.dispose()
composer.dispose()
Enter fullscreen mode Exit fullscreen mode

Controls and listeners. OrbitControls attaches to the DOM element. Not disposing it keeps the whole scene alive through the listener closure — a plain JavaScript leak on top of the GPU one.

controls.dispose()
Enter fullscreen mode Exit fullscreen mode

The renderer itself, when you're tearing down for good:

renderer.dispose()
renderer.forceContextLoss()
Enter fullscreen mode Exit fullscreen mode

A browser will only give a page a limited number of WebGL contexts — commonly around 16. Create-and-abandon in a single-page app and you'll eventually get a blank canvas with Too many active WebGL contexts in the console, which is the same bug wearing a different hat.

Measure it, don't guess

The renderer exposes exactly what you need:

console.log(renderer.info.memory)    // { geometries: 42, textures: 17 }
Enter fullscreen mode Exit fullscreen mode

Log this on every route change. In a healthy app the numbers return to baseline. If they only ever climb, you have your answer — and unlike a heap snapshot, this points directly at which resource type is leaking.

The framework wrapper

If you're on React Three Fiber, R3F disposes automatically when a component unmounts, which removes most of this problem. The exception is anything you constructed outside the render tree — a new THREE.TextureLoader().load(...) stored in a ref, a manually created render target — and those are exactly the cases where the automatic behaviour lulls you into not checking.

renderer.info.memory still tells the truth.

The takeaway

remove is about the scene graph. dispose is about the GPU. They are unrelated operations, and only one of them frees memory.


These posts come out of material I build for my Udemy courses — 25 of them now, mostly drill-based, across Go, Python, TypeScript, testing and Three.js. If this was useful, the full list is at udemy-c1f90.web.app. The links on that page carry a coupon I refresh each month, which usually lands around half the list price.

Top comments (0)