WebGL in Workers and Context Loss
Moving a WebGL renderer to a worker removes the main thread from the frame loop entirely: the render happens on another thread, and the compositor picks up the result without the page being involved. It also moves context loss to that thread, where nobody is watching for it.
This guide extends OffscreenCanvas Rendering, part of High-Performance Computation Patterns. Context loss is not an exceptional condition — a laptop switching GPUs on battery triggers it routinely — so a worker renderer without a restore path is a renderer that eventually shows a blank canvas until reload.
Setting Up the Context
// page.ts — transfer control once; the element stays in the DOM
const canvas = document.querySelector('canvas')!;
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ kind: 'INIT', canvas: offscreen, dpr: devicePixelRatio }, [offscreen]);
// renderer.worker.ts
let gl: WebGL2RenderingContext | null = null;
let scene: SceneDescription | null = null; // plain data — survives a loss
self.addEventListener('message', (event: MessageEvent) => {
if (event.data.kind !== 'INIT') return;
const canvas = event.data.canvas as OffscreenCanvas;
// Both listeners must be attached BEFORE the first frame is drawn.
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault(); // REQUIRED, or restore never fires
gl = null;
self.postMessage({ kind: 'CONTEXT_LOST' });
stopFrameLoop();
});
canvas.addEventListener('webglcontextrestored', () => {
gl = canvas.getContext('webgl2'); // a NEW context; nothing carried over
rebuildResources(gl!, scene!); // shaders, buffers, textures — all again
self.postMessage({ kind: 'CONTEXT_RESTORED' });
startFrameLoop();
});
gl = canvas.getContext('webgl2', { antialias: true, powerPreference: 'high-performance' });
rebuildResources(gl!, scene!);
startFrameLoop();
});
event.preventDefault() in the loss handler is the line that decides whether recovery is possible at all. Without it the browser treats the loss as final and never fires webglcontextrestored — the canvas stays blank permanently, and the only remedy is a page reload.
Making Resources Rebuildable
Recovery is a structural property, not a handler. The renderer needs one function that builds every GPU resource from plain data, called both at startup and on restore:
// resources.ts — the only place that creates GPU objects
export interface SceneDescription {
shaders: { vertex: string; fragment: string };
meshes: { positions: Float32Array; indices: Uint16Array }[];
textures: { bitmap: ImageBitmap; slot: number }[]; // keep the bitmaps, not just the handles
}
export function rebuildResources(gl: WebGL2RenderingContext, scene: SceneDescription): GpuState {
const program = linkProgram(gl, scene.shaders.vertex, scene.shaders.fragment);
const meshes = scene.meshes.map((mesh) => uploadMesh(gl, mesh));
const textures = scene.textures.map((t) => uploadTexture(gl, t.bitmap, t.slot));
return { program, meshes, textures, uniforms: locateUniforms(gl, program) };
}
Two rules make this reliable. Keep the CPU-side source of every GPU resource — the Float32Array, the ImageBitmap, the shader string — because re-fetching them over the network during a restore turns a 40 ms recovery into a multi-second one, and fails entirely offline. And never cache a WebGL handle outside GpuState: a texture id stashed in a material object will be stale after the rebuild, and the symptom is a render that draws with whatever happens to be bound.
Restore cost is dominated by texture upload, so a renderer with 200 MB of textures will not recover instantly. Prioritise: rebuild shaders and geometry first, draw a frame, then upload textures over the next few frames so the user sees something immediately.
Testing the Path You Cannot Trigger
Context loss is hard to provoke by accident and trivial to provoke deliberately, via the WEBGL_lose_context extension:
// dev only — force a loss and a restore to exercise the recovery path
const ext = gl.getExtension('WEBGL_lose_context');
ext?.loseContext();
setTimeout(() => ext?.restoreContext(), 1_000);
Wire that behind a development-only command and run it regularly. Three failures show up immediately and in no other way: a resource created outside rebuildResources, a stale handle held elsewhere, and a frame loop that keeps calling gl methods on a lost context and floods the console with warnings.
Add an end-to-end test too. Losing the context between two screenshots and asserting the second matches the first proves the restore path visually — the Playwright techniques in End-to-End Worker Tests with Playwright apply, with worker.evaluate used to trigger the loss inside worker scope.
Causes You Control
Some losses are the environment’s fault; several are not, and these are worth ruling out before blaming the driver.
Too many live contexts. Browsers cap the number of WebGL contexts per page — commonly around 16 — and creating a seventeenth silently loses the oldest. A component that creates a context per instance and never releases it will hit this, and the symptom is that older canvases go blank as new ones appear. Call loseContext() explicitly when a renderer is disposed.
Exhausting GPU memory. Uploading textures without bound eventually triggers a loss rather than an allocation error. Track uploaded bytes and evict, particularly on mobile where the budget can be a few hundred megabytes.
Very long single draw calls. A shader that takes seconds on a large draw can trip the operating system’s watchdog, which resets the GPU and loses every context on the machine. Break large workloads into several draws.
A background tab holding a context. Browsers reclaim contexts from backgrounded tabs. That is correct behaviour, and it means a tab returning to the foreground must be prepared to restore — the same handler covers it.
Telling the User Something Useful
Because the renderer lives on a worker, the page has no idea a loss happened unless the worker says so — and a canvas that silently stops updating is indistinguishable from an application that has hung.
A minimal protocol covers it: the worker posts CONTEXT_LOST and CONTEXT_RESTORED, and the page renders accordingly.
// page.ts
worker.addEventListener('message', (event: MessageEvent) => {
switch (event.data.kind) {
case 'CONTEXT_LOST':
overlay.textContent = 'Restoring graphics…';
overlay.hidden = false;
break;
case 'CONTEXT_RESTORED':
overlay.hidden = true;
break;
case 'CONTEXT_UNRECOVERABLE':
overlay.textContent = 'Graphics could not be restored. Reload to continue.';
break;
}
});
The third case matters as much as the first two. If webglcontextrestored has not fired within a few seconds, or if the rebuild itself throws, the renderer should give up deliberately and tell the page — a reload prompt is a far better outcome than an overlay that says “restoring” forever.
It is also worth counting restores. One in a session is normal; five in a minute means something in your own code is causing them — most often too many live contexts or unbounded texture uploads — and the count is what turns that from a hunch into a diagnosis.
Gotchas
Attaching the listeners after the first draw. A loss during initialisation then goes unhandled. Attach both handlers immediately after obtaining the canvas, before creating any resource.
Calling gl methods after a loss. Every call becomes a no-op and many log warnings, so a frame loop that keeps running floods the console and hides the real event. Stop the loop in the loss handler.
Assuming isContextLost() is enough. It reports the state but does not tell you when it changed. Use it as a guard inside the loop; use the events to drive the lifecycle.
Resizing the canvas from the page after transfer. Once control is transferred, the element’s width and height attributes no longer drive the drawing buffer — the worker owns it. Send the new size as a message and let the worker set canvas.width, or the render silently keeps its old resolution and appears blurry after a window resize.
Losing the canvas size on restore. OffscreenCanvas dimensions persist, but the viewport and any framebuffer attachments do not. Re-apply gl.viewport and re-create framebuffers during the rebuild.
Reporting a loss as a crash. Context loss is not an application error, and reporting it as one drowns the error stream. Log it as an event with a cause where known, and alert only when restores fail or become frequent — the classification argument in Production Error Telemetry.
Recreating the renderer instead of restoring it. Tearing down the worker and building a new one after a loss works, and it costs the module load, the scene transfer and every upload again — typically several times the rebuild path. Restore in place; reserve the full rebuild for the unrecoverable case.
No frame budget on the restore path. Rebuilding everything in one task can itself block the worker for hundreds of milliseconds, which delays the first restored frame. Slice the uploads across frames, exactly as with any other long task.
Performance Note
A worker-side WebGL2 renderer with 12 shader programs, 40 MB of geometry and 180 MB of textures recovered from a forced context loss in 310 ms on a 2023 laptop: 6 ms to relink programs, 22 ms to re-upload geometry, and the rest texture upload. Deferring textures over the following eight frames brought the time to first drawn frame down to 34 ms, with full fidelity restored by 420 ms — a recovery most users would not notice at all.
Treat a lost context the way a server treats a dropped connection: expected, recoverable, and worth exercising deliberately in development so the recovery path is as well tested as the render loop itself.