Three.js Memory Management — dispose(), WebGL Context Limits and Leak Detection

Every geometry, material and texture you create in Three.js allocates GPU memory. If you never call dispose(), that memory never comes back — and browsers cap WebGL contexts at 8–16 per page. This is everything we learned managing memory across 350 interactive simulations.

Why Three.js Memory Leaks Are Silently Catastrophic

JavaScript objects are garbage-collected automatically. WebGL resources are not. When you create a BufferGeometry, Material or Texture in Three.js, the library uploads data to the GPU. JavaScript's GC cannot reach into the GPU driver to free those allocations — only an explicit .dispose() call does.

On a page that creates and destroys scenes (like ours, where users switch between 350 simulations), a single forgotten dispose() call compounds into hundreds of orphaned GPU allocations within minutes, eventually causing black canvases, context-lost errors, and tab crashes.

The dispose() Pattern

function disposeScene(scene, renderer) { scene.traverse((obj) => { if (!obj.isMesh) return; // 1. Geometry — VBOs, IBOs on GPU obj.geometry?.dispose(); // 2. Material(s) — shader programs, uniforms const mats = Array.isArray(obj.material) ? obj.material : [obj.material]; for (const mat of mats) { // 3. Textures bound to the material for (const key of Object.keys(mat)) { if (mat[key]?.isTexture) mat[key].dispose(); } mat.dispose(); } }); // 4. Render targets (framebuffers + textures) renderer.renderLists.dispose(); }

Call disposeScene() before removing the canvas from the DOM and before calling renderer.dispose(). The order matters: material disposal triggers shader program deletion; renderer disposal tears down the context.

WebGL Context Limits

Chrome and Firefox enforce a hard cap on WebGL contexts per page (typically 8 in Chrome, 16 in Firefox as of 2026). Each new THREE.WebGLRenderer() consumes one context. Exceeding the cap silently kills the oldest context — your oldest simulation's canvas goes black.

Context Reuse

Share one renderer across simulations by resizing its canvas rather than creating a new renderer for each mount/unmount cycle.

Explicit Dispose

Call renderer.dispose() and renderer.forceContextLoss() when a simulation is genuinely unloaded, then null the reference.

Context Restore

Listen to canvas.addEventListener('webglcontextlost') and webglcontextrestored — rebuild geometry and textures on restore.

Texture Atlases

Pack multiple small textures into one atlas. Each Texture object is a separate GPU upload; atlases reduce both context state thrashing and VRAM fragmentation.

Detecting Leaks with renderer.info

renderer.info exposes live GPU allocation counts — the fastest way to confirm a dispose is working.

// Log before and after dispose console.log('Before:', { geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures, programs: renderer.info.programs?.length, }); disposeScene(scene, renderer); console.log('After:', { geometries: renderer.info.memory.geometries, textures: renderer.info.memory.textures, programs: renderer.info.programs?.length, }); // Expected: all counts drop to 0 (or shared resources count)

performance.memory for Heap Tracking

On Chromium browsers, performance.memory exposes usedJSHeapSize and totalJSHeapSize. While this tracks the JS heap (not VRAM directly), large texture data in ArrayBuffers does show up here before upload. Sample it every 5 seconds in development to catch trends:

// Development-only heap monitor if (import.meta.env.DEV && performance.memory) { setInterval(() => { const mb = (performance.memory.usedJSHeapSize / 1e6).toFixed(1); console.debug(`Heap: ${mb} MB`); }, 5_000); }

Rule of thumb: If you created it, you dispose it. Three.js does not own GPU memory — your application does. A useful mental model: treat BufferGeometry, Material and Texture like file handles. You always close a file handle when you're done with it.