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.
Step-by-Step Fix
- 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.
- 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.
- 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.
- 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. - Reuse instead of recreate. Update existing textures with
texSubImage2Dand buffers withbufferSubDatainstead of creating new ones per update; keep one context per canvas. Verification: allocation rate of GPU resources during steady rendering is zero. - Handle context loss. Listen for
webglcontextlost(callpreventDefault()), stop the render loop, and onwebglcontextrestoredrecreate resources from source data. Verification: forcing loss withWEBGL_lose_contextin development leads to a clean recovery.
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.
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.
Related
- Typed Arrays, Buffers and External Memory — the parent topic
- Map Library Instance Memory in Leaflet and Mapbox — disposing WebGL-backed map instances
- Why Chrome Task Manager and DevTools Report Different Memory — where GPU memory shows up
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview