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.

What a context loss destroys and what survives it Two columns. Destroyed by a context loss: every texture, vertex and index buffer, compiled shader and linked program, framebuffer and renderbuffer, all uniform locations and attribute locations, and the context object itself — every handle held in JavaScript becomes invalid. Surviving the loss: the scene description, the source data the textures were uploaded from, the shader source strings, camera and interaction state, and all ordinary JavaScript objects. A note records that a renderer able to rebuild the left column from the right column recovers; one that discarded its sources cannot. A lost context takes everything on the GPU destroyed · every texture, at every mip level · vertex and index buffers · compiled shaders and linked programs · framebuffers and renderbuffers · uniform and attribute locations · the context object itself every handle you hold is now invalid survives · the scene description · the sources textures were uploaded from · shader source strings · camera and interaction state · every ordinary JavaScript object · the worker itself, and its message queue enough to rebuild the left column — if you kept it The design question is not how to prevent a loss. It is whether the right-hand column is sufficient to reconstruct the left-hand one without a network request.
A renderer that uploads a decoded texture and then drops the source has traded recoverability for a few megabytes — usually a bad trade.

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.

The loss and restore sequence, with the step that is easy to omit A five-step sequence. The GPU or driver revokes the context. The canvas fires a webglcontextlost event; the handler must call preventDefault, stop the frame loop and mark the context unusable. If preventDefault is omitted, the sequence ends here permanently and the canvas stays blank. Otherwise the browser fires webglcontextrestored, the renderer obtains a new context and rebuilds every resource from its plain-data scene description, and the frame loop restarts. A note records that the whole path typically takes forty to four hundred milliseconds depending on texture volume. Loss to restore, and the branch that ends it early GPU revokes driver, power, memory webglcontextlost preventDefault + stop loop webglcontextrestored a brand-new context rebuild + draw from plain data no preventDefault blank canvas until reload The full path costs 40–400 ms depending on how much texture data has to be re-uploaded. Rebuild shaders and geometry first, draw, then stream textures over the following frames.
One missing line in the loss handler converts a recoverable event into a permanent failure, and nothing in the console says so.

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.


Causes of context loss you control, and what to do about each Four causes within the application's control. Too many live contexts silently loses the oldest, so dispose renderers explicitly. Unbounded texture uploads exhaust GPU memory and trigger a loss instead of an allocation error, so track uploaded bytes and evict. A very long single draw call can trip the operating system watchdog and reset the GPU for every context on the machine. And a backgrounded tab has its context reclaimed deliberately, which is correct behaviour the renderer must be ready for. Losses that are not the driver's fault too many live contexts — commonly capped near 16 creating another loses the oldest; call loseContext() on dispose unbounded texture uploads GPU memory exhaustion arrives as a loss, not an allocation error a single draw call that runs for seconds trips the OS watchdog and resets every context on the machine a backgrounded tab the browser reclaims it deliberately — be ready to restore Only the last row is expected behaviour. The other three are bugs with the same symptom.
Rule these out before blaming the driver: three of the four are visible in your own code.

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.

Frequently Asked Questions

What actually survives a WebGL context loss?
Nothing that lives on the GPU. Textures, buffers, shaders, programs, framebuffers and renderbuffers are all destroyed, and every WebGL object handle you hold becomes invalid. What survives is your JavaScript: the source data, the shader source strings, and the scene description. A renderer that can rebuild its GPU state from those survives a loss; one that uploaded a texture once and threw away the source cannot.
Why would a context be lost if my code is correct?
The GPU is a shared, preemptible resource. A driver update, a laptop switching between integrated and discrete graphics, the operating system reclaiming memory, another tab’s heavy workload, or the browser reclaiming a background tab’s context can all cause it. Context loss is a normal operating condition on the web, not an error state — which is why the restore path needs to be written before it is needed.

See also