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.