WebGL Texture and GPU Memory Leaks

A 3D viewer, map or chart library runs smoothly at first, then stutters, the GPU process in Chrome’s Task Manager climbs past a gigabyte, and eventually the canvas goes blank with a “WebGL context lost” warning. The JavaScript heap looks fine. This guide from Typed Arrays, Buffers and External Memory, in JavaScript Memory Fundamentals & Runtime Mechanics, explains why GPU resources need explicit deletion, how to find the ones you forgot, and how to survive context loss.

Symptom Root Cause Immediate Action Measurable Impact
GPU process memory climbs while JS heap is flat Textures/buffers created per scene or frame, never deleted Call deleteTexture/deleteBuffer/deleteFramebuffer on teardown GPU memory returns after scene changes
“WebGL: CONTEXT_LOST_WEBGL” after long sessions GPU memory exhausted or driver reset Handle webglcontextlost/restored; fix the leak App recovers instead of going blank
Library views leak on route change Renderer or map instance not disposed Call the library’s dispose()/remove() One context and its resources freed per unmount
Too many active WebGL contexts warning New context per component mount Reuse a context or call loseContext() on unmount Stays under the browser’s context limit
Large textures uploaded for small displays Full-resolution images uploaded Downscale before upload; use mipmaps wisely Texture memory proportional to display size

Root Cause: GPU Objects Are Handles, Not Garbage-Collected Memory

In WebGL, gl.createTexture(), gl.createBuffer(), gl.createFramebuffer() and gl.createRenderbuffer() return small JavaScript objects that are handles to resources living in GPU (or driver) memory. Uploading an image with texImage2D allocates width × height × bytes-per-pixel on the GPU — a 4096×4096 RGBA texture is 64 MB, and with a full mipmap chain about a third more. None of that is on the JavaScript heap, so heap snapshots show only the tiny handle objects.

Browsers may eventually free a GPU resource after its JavaScript handle is garbage collected, but that is not something to rely on: collection of a small handle object is not prioritised by heap pressure from memory the collector cannot see, and the timing is unpredictable. The WebGL API therefore provides explicit gl.deleteTexture(), gl.deleteBuffer(), gl.deleteFramebuffer(), gl.deleteRenderbuffer(), gl.deleteProgram() and gl.deleteShader(). Applications that create resources per scene, per tile, per chart update or — worst of all — per frame, and never delete them, leak GPU memory at whatever rate they create resources.

The same applies one level up. Every <canvas> with a WebGL context owns a context with its default framebuffer and state; browsers limit the number of simultaneously active contexts and start dropping the oldest when the limit is exceeded. Components that create a new renderer on each mount without disposing it accumulate contexts until the browser forcibly loses some. Libraries such as Three.js, Mapbox GL, deck.gl and many chart libraries wrap this with their own dispose()/remove() methods, which must be called — the pattern described in third-party library memory leaks.

When GPU memory runs out or the driver resets, the browser fires webglcontextlost on the canvas. All GPU resources are gone at that point; the application must stop rendering, and — if it called preventDefault() on the event — may get webglcontextrestored later and has to recreate everything.

Tiny handles, large GPU allocations On the left, the JavaScript heap holds three small WebGLTexture and WebGLBuffer handle objects of a few dozen bytes each. On the right, GPU memory holds the resources they refer to: a 64 megabyte 4K texture, a 16 megabyte texture and a 24 megabyte vertex buffer. Garbage collecting the handles does not promptly free the GPU memory; explicit gl.deleteTexture and gl.deleteBuffer calls do. JS heap WebGLTexture ~40 B WebGLTexture ~40 B WebGLBuffer ~40 B GPU memory 4096×4096 RGBA texture — 64 MB 2048×2048 RGBA texture — 16 MB vertex buffer — 24 MB free with gl.delete*()

Step-by-Step Fix

  1. Watch GPU memory, not just the heap. Open Chrome’s Task Manager (⋮ → More tools → Task manager) and enable the GPU memory column; watch the tab and the GPU process while switching scenes or routes. Verification: you see whether GPU memory returns after each scene change.
  2. Count live resources. Wrap resource creation and deletion in a small tracker (see the code below) that counts live textures, buffers and framebuffers. Verification: counts grow without bound during the leaking flow.
  3. Delete on teardown. For each scene, layer or component, keep a list of resources it created and delete them in its dispose path; also delete programs and shaders you no longer need. Verification: tracker counts return to their baseline after teardown.
  4. Dispose library instances. Call the renderer’s or map’s dispose()/remove() on unmount, then drop references to the instance and its canvas. Verification: the number of WebGL contexts (visible via warnings and the tracker) stays constant across mounts.
  5. Reuse instead of recreate. Update existing textures with texSubImage2D and buffers with bufferSubData instead of creating new ones per update; keep one context per canvas. Verification: allocation rate of GPU resources during steady rendering is zero.
  6. Handle context loss. Listen for webglcontextlost (call preventDefault()), stop the render loop, and on webglcontextrestored recreate resources from source data. Verification: forcing loss with WEBGL_lose_context in development leads to a clean recovery.
GPU memory across route changes A map view that is recreated on every route change without calling remove grows GPU memory from 180 megabytes to about 1.4 gigabytes over twenty changes, until the browser loses the context. With remove called on unmount, GPU memory stays around 190 megabytes. 1.5 GB 0 context lost map recreated without remove() remove() on unmount route changes 0 → 20

Command and Code Reference

Use case: a development-time tracker for GPU resources. It wraps the context so every create and delete is counted.

// gl-tracker.js — development only
export function trackGL(gl) {
  const live = { texture: 0, buffer: 0, framebuffer: 0, renderbuffer: 0 };
  for (const kind of Object.keys(live)) {
    const Kind = kind[0].toUpperCase() + kind.slice(1);
    const create = gl[`create${Kind}`].bind(gl);
    const del = gl[`delete${Kind}`].bind(gl);
    gl[`create${Kind}`] = () => { live[kind]++; return create(); };
    gl[`delete${Kind}`] = (obj) => { if (obj) live[kind]--; return del(obj); };
  }
  return live; // inspect or log periodically: counts should return to baseline
}

Use case: a scene that owns and releases its resources, with texture reuse.

class Scene {
  constructor(gl) {
    this.gl = gl;
    this.textures = [];
    this.buffers = [];
  }
  texture(image) {
    const t = this.gl.createTexture();
    this.gl.bindTexture(this.gl.TEXTURE_2D, t);
    this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, image);
    this.textures.push(t);
    return t;
  }
  updateTexture(t, image) {
    this.gl.bindTexture(this.gl.TEXTURE_2D, t);
    // same size: update in place instead of creating a new texture
    this.gl.texSubImage2D(this.gl.TEXTURE_2D, 0, 0, 0, this.gl.RGBA, this.gl.UNSIGNED_BYTE, image);
  }
  dispose() {
    for (const t of this.textures) this.gl.deleteTexture(t);  // frees GPU memory now
    for (const b of this.buffers) this.gl.deleteBuffer(b);
    this.textures.length = 0;
    this.buffers.length = 0;
  }
}

Use case: handle context loss and restoration.

canvas.addEventListener('webglcontextlost', (e) => {
  e.preventDefault();          // signal that we want a restore
  cancelAnimationFrame(frame); // stop rendering: all GPU resources are gone
}, false);

canvas.addEventListener('webglcontextrestored', () => {
  scene = buildScene(gl);      // recreate textures, buffers and programs from source data
  frame = requestAnimationFrame(render);
}, false);

Verification and Regression Prevention

Verify with the Task Manager’s GPU memory column and the resource tracker: after tearing down a scene or unmounting a view, GPU memory and live-resource counts should return to their previous levels, and repeated mount/unmount cycles should not increase either. Test context-loss handling deliberately with gl.getExtension('WEBGL_lose_context').loseContext() followed by restoreContext().

Keep the tracker in development builds and log a warning when live counts exceed expected maxima. Include a route-change soak test for pages with WebGL views, measuring GPU memory in headed Chrome, and make dispose() part of every component that owns a renderer. For 2D canvases and bitmaps, the analogous rules are in canvas and ImageBitmap memory in long-running apps.

GPU teardown verification Record the Task Manager GPU memory column and live resource counts, tear down the scene or unmount the view, and confirm both return to their previous levels over repeated cycles. Then test context-loss handling deliberately with the WEBGL_lose_context extension. Record GPU memory + resource counts Tear down delete textures, buffers, programs Back to baseline? over repeated cycles Force context loss WEBGL_lose_context

Edge Cases and Gotchas

Deleting a bound resource

Deleting a texture or buffer that is still bound or attached to a framebuffer marks it for deletion; memory is freed when it is no longer in use. Unbind and detach during teardown so deletion takes effect promptly.

Mipmaps add a third

generateMipmap adds roughly one third to a texture’s memory. Use mipmaps for textures viewed at varying scales; skip them for UI textures drawn at fixed size.

Canvas size is GPU memory too

The default framebuffer of a WebGL canvas is width × height × devicePixelRatio² × bytes per pixel, plus depth and stencil and possibly multisampling. A full-screen canvas on a high-DPI display can cost tens of megabytes before you create any texture.

Offscreen and worker rendering

OffscreenCanvas contexts in workers own GPU resources in the same way. Terminating the worker does not always free them immediately; dispose explicitly before termination.

Frequently Asked Questions

Does garbage collection free WebGL textures?

Not in a timely or reliable way. The handle object may eventually be collected and the browser may then free the resource, but GPU memory does not drive JavaScript GC. Delete textures, buffers and framebuffers explicitly when you are done with them.

Why does my heap snapshot not show the leak?

Because GPU resources live outside the JavaScript heap. Snapshots show only small WebGLTexture and WebGLBuffer handles. Use the Task Manager’s GPU memory column and a resource tracker to see the real cost.

What happens when the WebGL context is lost?

All GPU resources for that context are gone and rendering stops. If you call preventDefault() on the webglcontextlost event, the browser may later fire webglcontextrestored, and you must recreate every resource from your own source data.

How many WebGL contexts can a page have?

Browsers impose a limit, commonly around a dozen or so active contexts, and lose the oldest when it is exceeded. Reuse contexts, dispose renderer instances on unmount, and avoid one context per small widget.