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
removeis about the scene graph.disposeis 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.