Your 3D page gets slower every time someone switches a model or navigates between routes. Eventually the tab crashes, or the console shows WARNING: Too many active WebGL contexts. Oldest context will be lost. That’s a Three.js memory leak, and it happens because GPU resources are not garbage collected like normal JavaScript objects. This guide shows exactly what to dispose, how to stop the leak, and how to prove it’s fixed.
Quick fix
Removing an object from the scene does not free its GPU memory. Geometries, materials and textures stay uploaded until you call dispose() on them. When you remove a model, run it through a helper like this:
function disposeObject(root) {
root.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
const materials = Array.isArray(obj.material)
? obj.material
: obj.material ? [obj.material] : [];
for (const material of materials) {
// material.map, normalMap, roughnessMap, envMap, etc.
for (const value of Object.values(material)) {
if (value && value.isTexture) value.dispose();
}
// textures passed to ShaderMaterial uniforms
if (material.uniforms) {
for (const u of Object.values(material.uniforms)) {
if (u.value && u.value.isTexture) u.value.dispose();
}
}
material.dispose();
}
if (obj.isSkinnedMesh) obj.skeleton.dispose();
});
root.removeFromParent();
}
Then drop every JavaScript reference to the model, such as arrays, maps and state, so the CPU side can be garbage collected too. Check renderer.info.memory before and after to confirm the numbers go back down.
Cause 1: removing objects without disposing them
scene.remove(mesh) only detaches the object from the scene graph. The vertex buffers, compiled shader programs and texture uploads stay on the GPU. In a product configurator, every colour or model swap that creates new geometry or materials without disposing the old ones adds to the pile.
Two details catch people out:
material.dispose()does not dispose its textures. You must disposemap,normalMap,roughnessMapand the rest yourself, which is why the helper loops over the material’s properties.- Shared resources. If two meshes share a material or texture and you dispose it while one is still visible, Three.js just uploads it again on the next render. You’ve freed nothing and paid for a re-upload. Track ownership: only dispose what nothing else uses.
If you loaded textures with ImageBitmapLoader, also call texture.source.data.close() to release the decoded bitmap.
Cause 2: creating a new renderer per view or route
Each WebGLRenderer creates its own WebGL context. Browsers allow only a small number of live contexts. When you exceed it, the oldest one is lost and you see Too many active WebGL contexts. This is common in single-page apps where a component creates a renderer on mount and never cleans it up, and in React development mode, where effects mount twice.
Clean up the renderer fully when the view unmounts:
let frameId;
function animate() {
frameId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
function destroy() {
cancelAnimationFrame(frameId); // or renderer.setAnimationLoop(null)
window.removeEventListener('resize', onResize);
controls.dispose(); // removes OrbitControls DOM listeners
disposeObject(scene);
scene.environment?.dispose();
composer?.dispose(); // if you use EffectComposer
renderer.dispose();
renderer.forceContextLoss(); // release the context now, not later
renderer.domElement.remove();
}
Better still, reuse one renderer. Keep a single canvas mounted at app level and swap scenes into it. If you need several 3D views on one page, render them into one canvas with scissor viewports instead of creating several renderers. In react-three-fiber, drei’s <View> component does this for you.
Cause 3: animation loops and listeners that keep running
A requestAnimationFrame loop that is never cancelled keeps a reference to the renderer, scene and camera, so none of them can be garbage collected. Even after the DOM node is gone, the loop keeps rendering into a detached canvas. The same applies to resize, pointermove and ResizeObserver callbacks that close over the scene. Every listener you add needs a matching removal in your cleanup.
Cause 4: render targets, PMREM and post-processing
WebGLRenderTargets hold full-resolution textures and are easy to forget. Dispose of them yourself, along with the PMREMGenerator once you’ve built your environment map, and anything created per resize:
const pmrem = new THREE.PMREMGenerator(renderer);
const envMap = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environment = envMap;
pmrem.dispose(); // the generated envMap stays usable
// later, when tearing down
envMap.dispose();
renderTarget.dispose();
Cause 5: react-three-fiber specifics
react-three-fiber automatically calls dispose() on objects it created when they unmount. That covers JSX meshes, geometries and materials. It has limits:
<primitive object={...} />is not disposed. If you mount a cloned glTF scene withprimitive, you own its cleanup.- Shared assets get disposed when you don’t want them to be. If a geometry or material is shared between components, unmounting one disposes it for all of them. Mark shared assets with
dispose={null}, which also applies to their children. useGLTFanduseTexturecache by URL. That’s good for reuse, but cached assets stay in memory for the lifetime of the page. When a model is truly gone, for example after leaving a configurator, clear it withuseGLTF.clear(url)and dispose its resources.- Creating resources in the component body.
new THREE.TextureLoader().load()ornew THREE.MeshStandardMaterial()outsideuseMemocreates a new GPU resource on every render.
import { useGLTF } from '@react-three/drei';
import { useEffect } from 'react';
function Product({ url }) {
const { scene } = useGLTF(url);
useEffect(() => {
return () => {
disposeObject(scene); // helper from above
useGLTF.clear(url); // drop it from the cache
};
}, [scene, url]);
return <primitive object={scene} dispose={null} />;
}
Only clear the cache when you’re sure nothing else on the page is using that URL. Otherwise the next component loads a disposed asset.
Less obvious causes experienced developers miss
- Geometry created every frame. Updating text, lines or charts by building a new
BufferGeometryinside the render loop leaks constantly. Update the existing attribute in place and setattribute.needsUpdate = true. - Cloning materials per instance.
material.clone()for each of hundreds of objects multiplies programs and uniforms. UseInstancedMeshwith per-instance colour, or share materials. - Disposed, but still referenced.
dispose()frees GPU memory, but an object kept in an undo stack, a selection array or the result ofraycaster.intersectObjects()stays in the JS heap. - Hot module reload. In development, HMR can re-run module-level setup code and create a new renderer each time. Before you chase a leak, check that it also happens in a production build.
Step-by-step diagnosis
- Log
renderer.info.memory(geometries,textures) andrenderer.info.programs.lengthafter each model swap or route change. - Repeat the same action several times. If the counts keep climbing instead of levelling off, you’ve found the leak.
- Open Chrome DevTools, go to Memory, and take a heap snapshot before and after the action. Filter by
Mesh,BufferGeometryorWebGLRendererto see which objects survive and what still references them. - Use the Performance monitor panel to watch JS heap size and DOM node count over time.
- Search the console for
Too many active WebGL contextsandCONTEXT_LOST_WEBGL.
function logMemory(label) {
const { geometries, textures } = renderer.info.memory;
console.log(label, { geometries, textures, programs: renderer.info.programs.length });
}
logMemory('before swap');
swapModel(nextUrl);
logMemory('after swap');
Prevention
- Pair every
createwith adisposein the same module, and write the cleanup function when you write the setup. - Keep one renderer for the life of the app.
- Cache and share textures and materials on purpose, with clear ownership.
- Add a
renderer.info.memoryreadout to your dev overlay so regressions are obvious. - More techniques for keeping GPU usage low are in my Three.js performance optimisation guide.
Key takeaways
- Removing from the scene is not disposing. Call
dispose()on geometries, materials and every texture. - Dispose of render targets, PMREM generators, composers, controls and finally the renderer.
- Cancel animation loops and remove listeners, or nothing gets garbage collected.
- Reuse one renderer instead of creating one per view or route.
- In react-three-fiber, watch out for primitives, shared assets (
dispose={null}) and theuseGLTFcache.
Frequently asked questions
Does scene.remove() free memory in Three.js?
No. It only detaches the object from the scene graph. Call dispose() on its geometry, materials and textures to free GPU memory, then drop your JavaScript references.
How do I fix “Too many active WebGL contexts”?
You’re creating more WebGLRenderer instances than the browser allows. Reuse a single renderer, or dispose old ones with renderer.dispose() and renderer.forceContextLoss() when their view unmounts.
Does react-three-fiber dispose objects automatically?
Yes, for objects it creates through JSX when they unmount. It does not dispose of primitive objects. It also caches useGLTF assets until you call useGLTF.clear().
How can I measure GPU memory in Three.js?
Read renderer.info.memory.geometries, renderer.info.memory.textures and renderer.info.programs.length before and after an action. Use the browser’s Memory panel heap snapshots to find objects that are still referenced.
If your 3D app slows down or crashes after a few minutes of use, I can help track down the leak and restructure the rendering so it stays stable. Get in touch. For how I structure long-running configurator scenes, see building a 3D product configurator.