OffscreenCanvas Rendering

OffscreenCanvas moves the entire canvas rendering pipeline — context state, draw calls, and the requestAnimationFrame loop — off the main thread and into a worker. It is a specialisation of High-Performance Computation Patterns applied to the one workload that is judged frame by frame: pixels arriving on time.

The Jank Symptom

The complaint is always phrased the same way — “the chart stutters whenever the page is doing anything else.” A candlestick chart redraws 4,000 line segments per frame, a WebGL globe spins at 60 fps, or a dashboard animates a dozen sparklines, and every one of them hitches the moment a route change, a JSON parse, or a framework re-render lands on the main thread.

Record it in the Chrome Performance panel and the shape is unmistakable. The Main track shows one long task — say a 180 ms JSON.parse of an API response — and directly beneath it the Frames lane shows eleven consecutive dropped frames. The drawing code is innocent: each frame costs 3 ms. It never got the chance to run, because canvas drawing and application JavaScript share one event loop and one 16.7 ms budget per frame at 60 Hz.

That distinction is worth internalising before writing any code:

  • Slow drawing is a rendering problem. A 40 ms frame costs 40 ms whichever thread it runs on, and OffscreenCanvas will not rescue it — reduce the geometry, batch the state changes, or move to WebGL.
  • Blocked drawing is a scheduling problem. The frame is cheap but never gets scheduled, because something unrelated owns the thread. That is exactly what OffscreenCanvas fixes, and it fixes it completely: the worker’s rAF loop is driven by the compositor and is indifferent to main-thread activity.
Frame budget with rendering on the main thread versus in a worker Two one-second timelines. With rendering on the main thread a 180 millisecond JSON.parse task occupies eleven consecutive 16.7 millisecond frame slots and eleven frames are dropped, even though each draw costs only 3 milliseconds. With rendering in a worker the same task runs on the main thread while the worker keeps a 3 millisecond draw inside every slot and no frame is dropped. Rendering on the main thread one event loop · one 16.7 ms budget shared by script and drawing 20 slots × 16.7 ms Main JSON.parse — 180 ms of main-thread work Frames 11 consecutive frames dropped — the 3 ms draw was never scheduled Rendering in a worker the same long task · a rAF loop the compositor drives 3 ms draw inside every slot Main JSON.parse — 180 ms of main-thread work Worker Frames every frame presented — the worker’s rAF loop never queued behind the task
Same workload, two schedules. OffscreenCanvas does not make the 3 ms draw cheaper — it stops the draw from queueing behind a 180 ms main-thread task.
Prerequisites

Confirm all of the following before implementing: a bundler or runtime that can load a module worker (new Worker(url, { type: 'module' })); a secure context — HTTPS or localhost; working knowledge of the Canvas 2D or WebGL2 API; and an understanding of Transferable Objects & Zero-Copy, because OffscreenCanvas is itself a transferable whose ownership must be moved explicitly. No browser flags are needed on any current engine; the only real gate is Safari 16.4 or newer.

How Control Transfer Works

transferControlToOffscreen() splits one canvas into two halves that live on different threads. The DOM element keeps everything layout- and input-related: its CSS box, its place in the document flow, and the pointer, wheel, and keyboard events that hit it. The OffscreenCanvas object takes everything pixel-related: the backing store, the rendering context, and the exclusive right to draw.

Composition is unchanged. The worker’s draw calls land in the same surface the compositor already owned for that element, so output appears in the page exactly where the <canvas> sits, at the same z-index, with the same CSS filters and transforms applied. Nothing is copied back to the main thread to make that happen, which is why the pattern costs essentially nothing per frame once it is running.

OffscreenCanvas control transfer from main thread to worker The main thread holds the DOM canvas element; after transferControlToOffscreen the OffscreenCanvas moves to the worker which owns the rAF render loop. Data deltas and resize events flow from main to worker via postMessage. Main Thread DOM · input · layout · paint <canvas> DOM element placeholder after transfer transferControlToOffscreen() ResizeObserver → postMessage data deltas → postMessage Dedicated Worker no DOM · isolated heap OffscreenCanvas 2d / webgl / webgl2 context self.requestAnimationFrame loop draw commands → GPU onmessage: resize + data transfer postMessage postMessage
After transferControlToOffscreen(), the DOM canvas becomes a display placeholder. The worker owns the OffscreenCanvas and drives the entire rAF render loop independently of main-thread activity.

The transfer is a one-way door. There is no returnControlToMainThread(), no way to re-acquire a 2D context on the element, and no second transfer. Design the ownership model up front: whichever thread will draw the last frame of that element’s life must be the thread that receives it. Transferring Canvas Control to a Worker walks through the handshake in isolation if you want the smallest possible reproduction before wiring it into an app.

Step-by-Step Implementation

Step 1 — Transfer canvas control

Call canvas.transferControlToOffscreen() on the main thread, then post the returned object to the worker in the transfer list. Putting it only in the payload throws a DataCloneError: an OffscreenCanvas is transferable but not cloneable.

// main.ts
const canvas = document.getElementById('chart') as HTMLCanvasElement;

// Transfer BEFORE anything calls canvas.getContext() on this element.
const offscreen: OffscreenCanvas = canvas.transferControlToOffscreen();

const worker = new Worker(new URL('./render-worker.ts', import.meta.url), {
  type: 'module',
});

const rect = canvas.getBoundingClientRect();

worker.postMessage(
  {
    type: 'init',
    canvas: offscreen,
    width: Math.round(rect.width),
    height: Math.round(rect.height),
    dpr: window.devicePixelRatio,
  },
  [offscreen] // transfer ownership — `offscreen` is detached on this side now
);

After that call, offscreen.width and offscreen.height read 0 on the main thread: the object is detached and the worker holds the only live handle to the backing store. Send the CSS box size and devicePixelRatio in the same init message so the worker can size its backing store before the first frame instead of drawing one frame at the wrong scale.

One transfer, and only before first paint

transferControlToOffscreen() throws InvalidStateError if the element already has a rendering context, or if it has been transferred once already. Calling canvas.getContext() after the transfer throws as well. A common failure in component frameworks is a chart library that grabs a 2D context during mount — transfer must happen before that library ever touches the element, or you need a fresh <canvas>.

Step 2 — Acquire the context and own the frame loop

Inside the worker, receive the OffscreenCanvas, acquire a context, size the backing store, and start a requestAnimationFrame loop. self.requestAnimationFrame exists in dedicated worker scopes precisely so worker-side rendering can be driven by the display’s vsync signal rather than by timers.

// render-worker.ts
interface InitMessage {
  type: 'init';
  canvas: OffscreenCanvas;
  width: number;
  height: number;
  dpr: number;
}
interface ResizeMessage {
  type: 'resize';
  width: number;
  height: number;
  dpr: number;
}
type Incoming = InitMessage | ResizeMessage;

let ctx: OffscreenCanvasRenderingContext2D | null = null;
let dpr = 1;
let rafId = 0;

self.onmessage = (e: MessageEvent<Incoming>) => {
  const msg = e.data;

  if (msg.type === 'init') {
    ctx = msg.canvas.getContext('2d');
    if (!ctx) {
      self.postMessage({ type: 'error', message: 'no 2d context in worker' });
      return;
    }
    applySize(msg.width, msg.height, msg.dpr);
    rafId = self.requestAnimationFrame(renderFrame);
  }

  if (msg.type === 'resize') {
    applySize(msg.width, msg.height, msg.dpr);
  }
};

function applySize(cssWidth: number, cssHeight: number, ratio: number): void {
  if (!ctx) return;
  dpr = ratio;
  // Writing width/height reallocates the backing store AND resets all
  // context state, so the transform is reapplied immediately after.
  ctx.canvas.width = Math.round(cssWidth * dpr);
  ctx.canvas.height = Math.round(cssHeight * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}

function renderFrame(timestamp: number): void {
  if (!ctx) return;
  // Clear in CSS pixels: the transform already scales by dpr.
  ctx.clearRect(0, 0, ctx.canvas.width / dpr, ctx.canvas.height / dpr);
  drawScene(ctx, timestamp);
  rafId = self.requestAnimationFrame(renderFrame);
}
Why the loop keeps running

A worker's requestAnimationFrame callbacks are scheduled against the display's refresh, not against main-thread script. While the main thread executes a 200 ms parse, the worker keeps producing frames at the full refresh rate and the compositor keeps presenting them. The frames that do get dropped are the ones the worker itself misses — which is why per-frame instrumentation belongs inside the worker, not outside it.

Step 3 — Choose 2D or WebGL2 inside the worker

OffscreenCanvasRenderingContext2D mirrors CanvasRenderingContext2D almost exactly, with two differences that matter in practice. There is no canvas.style, because there is no element to style — every visual decision must be expressed in draw calls. And image sources must be ImageBitmap (or another canvas), never HTMLImageElement, because DOM classes do not exist in a worker. Decode with createImageBitmap(blob) inside the worker, or decode on the main thread and transfer the bitmap in.

// render-worker.ts — 2D drawing, with an ImageBitmap sprite
let sprite: ImageBitmap | null = null;

async function loadSprite(url: string): Promise<void> {
  const res = await fetch(url);
  // fetch + createImageBitmap both work in a worker scope; decode cost
  // stays off the main thread as well.
  sprite = await createImageBitmap(await res.blob());
}

function drawScene(
  ctx: OffscreenCanvasRenderingContext2D,
  t: number
): void {
  ctx.fillStyle = '#c8922a';
  ctx.fillRect(0, 0, 240, 120);
  if (sprite) ctx.drawImage(sprite, Math.sin(t / 500) * 40, 0);
}

For scenes with thousands of primitives, take a WebGL2 context instead. The API is identical to the main-thread one; only the acquisition differs.

// render-worker.ts — WebGL2 inside a worker
function initGL(canvas: OffscreenCanvas): WebGL2RenderingContext | null {
  const gl = canvas.getContext('webgl2', {
    antialias: true,
    // The compositor never needs to read these pixels back, so let the
    // driver discard the buffer after each present.
    preserveDrawingBuffer: false,
    powerPreference: 'high-performance',
  }) as WebGL2RenderingContext | null;

  if (!gl) {
    self.postMessage({ type: 'error', message: 'webgl2 unavailable' });
    return null;
  }

  gl.canvas.addEventListener?.('webglcontextlost', (ev: Event) => {
    ev.preventDefault(); // required, or the context is never restorable
    self.postMessage({ type: 'context-lost' });
  });

  return gl;
}
Context budget is per page, not per thread

Browsers cap simultaneous WebGL contexts at roughly 8–16 per page, and moving a canvas into a worker does not buy you extra. Chrome silently drops the least-recently-used context when the cap is exceeded, which surfaces as a blank canvas or a webglcontextlost event rather than an exception. Pool contexts across views, or fall back to 2D for the small ones, and always handle getContext() returning null.

Step 4 — Keep resize and devicePixelRatio correct

ResizeObserver only exists on the main thread, so resizing is a two-thread dance: observe on the main thread, apply in the worker. Use devicePixelContentBoxSize where it is available — it reports the box in real device pixels and removes the rounding error you get from multiplying a fractional CSS width by a fractional devicePixelRatio on scaled displays.

// main.ts — observe on the main thread, apply in the worker
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const devicePx = entry.devicePixelContentBoxSize?.[0];
    const cssBox = entry.contentBoxSize?.[0];

    if (devicePx) {
      // Exact device pixels: dpr is already baked in.
      worker.postMessage({
        type: 'resize',
        width: devicePx.inlineSize,
        height: devicePx.blockSize,
        dpr: 1,
      });
    } else if (cssBox) {
      worker.postMessage({
        type: 'resize',
        width: cssBox.inlineSize,
        height: cssBox.blockSize,
        dpr: window.devicePixelRatio,
      });
    }
  }
});
ro.observe(canvas, { box: 'device-pixel-content-box' });

// devicePixelRatio changes when the window moves between monitors or the
// user zooms; ResizeObserver alone will not always fire for it.
matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`).addEventListener(
  'change',
  () => {
    const r = canvas.getBoundingClientRect();
    worker.postMessage({
      type: 'resize',
      width: Math.round(r.width),
      height: Math.round(r.height),
      dpr: window.devicePixelRatio,
    });
  },
  { once: true }
);

The worker side is the applySize() function from Step 2: assign width/height, then immediately restore the transform.

Assigning width or height wipes context state

Setting canvas.width or canvas.height — even to the same value — reallocates the backing store and resets the whole 2D context: transform, fillStyle, clip region, line settings. Guard the assignment behind an equality check so a no-op resize does not clear a frame, and reapply setTransform(dpr, 0, 0, dpr, 0, 0) straight afterwards. Skipping the transform is the single most common cause of blurry output on HiDPI displays.

Resize handshake between the DOM canvas, the main thread and the render worker A sequence across three lifelines. The canvas content box changes, ResizeObserver reports it on the main thread, a resize message carries width, height and devicePixelRatio to the worker, the worker reassigns the backing store which resets context state, reapplies setTransform, and the next animation frame draws at the correct scale. A dashed branch shows the failure path when the transform is not reapplied: blurry, half-scale output. <canvas> element CSS box only Main thread ResizeObserver lives here Render worker owns the backing store content box changes devicePixelContentBoxSize[0] postMessage { type: ‘resize’, width, height, dpr } offscreen.width / .height = w, h backing store reallocated · context state reset setTransform(dpr, 0, 0, dpr, 0, 0) scale reapplied before anything draws next rAF frame is sharp at any devicePixelRatio blurry, half-scale output every frame until the next resize transform not reapplied
One resize, two threads. ResizeObserver only exists on the main thread; the worker owns the backing store — and reassigning width or height wipes the transform that keeps HiDPI output sharp.

Step 5 — Read pixels back when you need them

Most render loops never send pixels back — the compositor already shows them. Two cases need a readback: exporting a frame (a “download chart as PNG” button) and compositing the worker’s output into a canvas the main thread still owns.

// render-worker.ts — export the current frame
async function exportPng(offscreen: OffscreenCanvas): Promise<void> {
  // Encoding happens on the worker thread; the main thread never stalls.
  const blob = await offscreen.convertToBlob({ type: 'image/png' });
  self.postMessage({ type: 'frame-blob', blob }); // Blob is cloneable
}

// render-worker.ts — hand the current frame over as a GPU-side handle
function exportBitmap(offscreen: OffscreenCanvas): void {
  // transferToImageBitmap() takes the backing store and leaves the canvas
  // cleared, so call it at the END of a frame, never mid-draw.
  const bitmap: ImageBitmap = offscreen.transferToImageBitmap();
  self.postMessage({ type: 'frame-bitmap', bitmap }, [bitmap]);
}
// main.ts — receive either form
worker.onmessage = (e: MessageEvent) => {
  if (e.data.type === 'frame-bitmap') {
    const bitmap: ImageBitmap = e.data.bitmap;
    displayCtx.drawImage(bitmap, 0, 0);
    bitmap.close(); // release the GPU allocation immediately
  }
  if (e.data.type === 'frame-blob') {
    const url = URL.createObjectURL(e.data.blob as Blob);
    downloadLink.href = url;
    downloadLink.addEventListener('click', () => URL.revokeObjectURL(url), {
      once: true,
    });
  }
};
Bitmap transfer is cheap; encoding is not

transferToImageBitmap() moves ownership of the backing store and is sub-millisecond regardless of resolution. convertToBlob() runs a real PNG encode — tens of milliseconds for a 1920×1080 frame, and it scales with pixel count. Use bitmap transfer for anything per-frame; reserve blob encoding for explicit save/snapshot actions, and never call it inside the rAF loop. Every ImageBitmap you receive must be close()d, or GPU memory grows until the tab is discarded.

Step 6 — Forward input for hit-testing

Rendering moved; input did not. Pointer, wheel, and keyboard events still fire on the DOM <canvas>, while the scene graph that knows what is under the cursor now lives in the worker. Convert coordinates to the worker’s pixel space on the main thread, and let the worker answer the hit test.

// main.ts — forward pointer position in device pixels
let boxLeft = 0;
let boxTop = 0;

// getBoundingClientRect() forces layout — read it on resize/scroll, not per move.
function cacheBox(): void {
  const r = canvas.getBoundingClientRect();
  boxLeft = r.left;
  boxTop = r.top;
}
cacheBox();
addEventListener('scroll', cacheBox, { passive: true });
addEventListener('resize', cacheBox);

canvas.addEventListener(
  'pointermove',
  (e: PointerEvent) => {
    worker.postMessage({
      type: 'pointer',
      x: (e.clientX - boxLeft) * window.devicePixelRatio,
      y: (e.clientY - boxTop) * window.devicePixelRatio,
    });
  },
  { passive: true }
);
One message per pointer event is too many

A high-rate mouse fires pointermove at 120–1000 Hz. Posting each one floods the worker's queue and can starve its rAF callbacks — the exact jank you moved rendering to avoid. Store the latest position in a variable and post it at most once per main-thread animation frame, or write it into a two-element Float32Array backed by shared memory so the worker simply reads the current value when it draws.

Data-Transfer Strategy

Once the canvas itself has been handed over, the remaining traffic is the data that drives each frame. There are four options, and the right one depends on payload size and update rate rather than taste.

Strategy Cost per message Source survives? Use for
Structured clone of a small object Under 0.1 ms below ~10 KB Yes Config, theme, discrete state changes
ArrayBuffer in the transfer list Sub-millisecond at any size No — detached Streaming series data, pixel buffers, geometry
ImageBitmap in the transfer list Sub-millisecond No — detached Decoded textures and sprites sent into the worker
SharedArrayBuffer + Atomics No message at all Yes — both threads read it High-frequency scalars: cursor, camera, playhead

For streaming values, post typed arrays rather than JSON. A Float32Array of 1,000 samples is 4 KB and transfers in constant time; the same data as a JSON string is 10–20 KB, must be serialised on the sender and parsed on the receiver, and both halves of that cost land on the thread you were trying to protect. The same reasoning drives the pixel pipelines in Image Processing in Workers, and the general rules are collected in Transferable Objects & Zero-Copy.

// main.ts — send a frame's data as a transferable
function sendSeries(values: Float32Array): void {
  // Post the buffer, not the view: after this the main thread must not
  // touch `values` again (values.length is now 0).
  worker.postMessage({ type: 'data', buffer: values.buffer }, [values.buffer]);
}

// render-worker.ts — adopt it with zero copy
let series = new Float32Array(0);

function onData(buffer: ArrayBuffer): void {
  series = new Float32Array(buffer); // wraps, does not copy
}

Transfer has one structural drawback for a render loop: the sender loses the buffer, so a producer that updates 120 times per second must allocate a fresh buffer each time and let the GC clean up after it. Where the payload is small and constantly changing — a cursor position, a camera matrix, a scrub position — a SharedArrayBuffer avoids both the allocation and the message entirely. The worker reads the latest value when it draws, and no frame is ever spent waiting on a queue.

SharedArrayBuffer requires cross-origin isolation

Constructing a SharedArrayBuffer throws unless the document is cross-origin isolated. Serve it with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, and check self.crossOriginIsolated before taking this path. Every cross-origin subresource then needs CORP or CORS headers, which frequently rules the approach out for pages carrying third-party embeds. Also use Atomics.store/Atomics.load for anything the worker reads mid-frame — see SharedArrayBuffer & Atomics for the memory-ordering rules and a ring-buffer implementation.

Choosing a transport for per-frame data sent to a render worker A decision tree. Small discrete state uses a structured clone; a large buffer rebuilt each frame is transferred as an ArrayBuffer and detaches on the sender; a decoded image is transferred as an ImageBitmap and closed after use; one tiny value updated faster than the frame rate reaches a cross-origin-isolation gate, where an isolated page uses SharedArrayBuffer with Atomics and a non-isolated page falls back to transferring a fresh buffer each frame. Per-frame payload choose by size and update rate, not by taste small, discrete state config, theme, view flags structured clone postMessage(obj) — under 0.1 ms big buffer, rebuilt each frame series, geometry, pixel data ArrayBuffer transfer postMessage(m, [buf]) — sender detaches an image you decoded sprite, tile, texture atlas ImageBitmap transfer transfer in, then bitmap.close() one tiny value, updated faster than the frame rate crossOriginIsolated? COOP: same-origin + COEP: require-corp no fall back to transfer a fresh buffer per frame, GC pays yes SharedArrayBuffer + Atomics no message at all — read it when you draw
Four transports, one question each. Transfer detaches the sender’s buffer, so a producer running faster than the display either allocates every frame or moves to shared memory — if the document is cross-origin isolated.

Verification & Measurement

The claim being tested is narrow: rendering work no longer appears on the main thread, and frames are no longer dropped while the main thread is busy. Both are directly observable.

In the Chrome Performance panel. Record 3–5 seconds with the animation running and something expensive happening on the main thread. Expand the Worker track: requestAnimationFrame callbacks, drawImage, and rasterisation should sit there, and the Main track should show only your postMessage calls and event handlers. The Frames lane is the verdict — the pre-change profile shows tall partially-presented frames aligned with each long task; the post-change profile keeps a steady cadence through the same task. Profiling Worker CPU Usage with the Chrome Performance Tab covers reading those tracks in detail.

From inside the worker. DevTools tells you about a captured window; instrumentation tells you about production. Measure both the interval between frames (did we miss vsync?) and the cost of drawing (are we near the budget?).

// render-worker.ts — self-reported frame health
let prev = 0;
let over = 0;
let frames = 0;
let drawTotal = 0;

function renderFrame(timestamp: number): void {
  if (!ctx) return;

  const gap = prev ? timestamp - prev : 0;
  prev = timestamp;
  // A dropped frame at 60 Hz shows up as a gap of ~33 ms (or more).
  if (gap > 24) over++;

  const t0 = performance.now();
  drawScene(ctx, timestamp);
  drawTotal += performance.now() - t0;
  frames++;

  if (frames === 120) {
    self.postMessage({
      type: 'frame-stats',
      avgDrawMs: +(drawTotal / frames).toFixed(2),
      missedFrames: over,
    });
    frames = 0;
    over = 0;
    drawTotal = 0;
  }

  self.requestAnimationFrame(renderFrame);
}

Useful thresholds on mid-range hardware: keep average draw time under 8 ms for a 60 Hz target (under 4 ms if you also support 120 Hz displays), and treat a missed-frame rate above roughly 2% of a 120-frame window as a regression worth investigating. A rising avgDrawMs with a flat missed-frame count means the scene is getting heavier; a flat avgDrawMs with rising misses usually means the worker’s message queue is saturated — see the pointer-event throttling note in Step 6.

What a healthy post-transfer Performance recording looks like A mock of three DevTools tracks. The Main track holds only sparse blocks of application work and postMessage calls. The Worker track holds an evenly spaced requestAnimationFrame block for every vsync. The Frames track shows an unbroken 16.7 millisecond cadence. Below, two cards give the in-worker thresholds: average draw time at or under 8 milliseconds, and missed frames under 2 percent of a 120-frame window. Performance panel — 3 s recording, rendering already moved into the worker Main your app code event handlers and postMessage only — no rAF callbacks, no paint Worker rendering requestAnimationFrame → drawScene → raster, once per vsync Frames presented unbroken 16.7 ms cadence — no partially-presented frames beside the long task avgDrawMs — is the scene too heavy? under 8 ms for a 60 Hz target, under 4 ms at 120 Hz rising here means geometry, not scheduling fix by batching state changes or moving to WebGL2 missedFrames — is the worker starved? over 2% of a 120-frame window is a regression flat avgDrawMs + rising misses = saturated queue fix by coalescing pointer posts to one per frame
The verdict lives in three tracks and two counters: drawing work sits in the Worker track, the Frames lane keeps its cadence, and the worker’s own numbers say whether the scene or the queue is the next problem.

Failure Modes & Error Handling

The context comes back null

getContext() returns null rather than throwing when the request cannot be satisfied — the canvas was already given a context of a different type, the WebGL context cap is exhausted, or hardware acceleration is disabled (common in VMs, remote desktops, and some enterprise Chrome policies). Every acquisition needs a null check that reports back to the main thread, because a worker that silently stops drawing is indistinguishable from a slow one.

The worker dies and the canvas freezes

If the worker throws in a way it does not survive, the canvas holds its last frame forever. Nothing on the main thread repaints it, and the transfer cannot be reversed — recovery means creating a new <canvas> element and a new worker. Serialise errors explicitly, because ErrorEvent objects do not structured-clone, and pair that with a heartbeat so a hung (rather than crashed) worker is also detected.

// render-worker.ts — structured error reporting + heartbeat
function report(err: unknown): void {
  const e = err instanceof Error ? err : new Error(String(err));
  self.postMessage({
    type: 'error',
    name: e.name,
    message: e.message,
    stack: e.stack ?? null,
  });
}

self.addEventListener('error', (ev: ErrorEvent) => report(ev.error ?? ev.message));
self.addEventListener('unhandledrejection', (ev: PromiseRejectionEvent) =>
  report(ev.reason)
);

// Sent from inside the rAF loop, so it stops if rendering stops.
setInterval(() => self.postMessage({ type: 'heartbeat', at: Date.now() }), 500);
// main.ts — watchdog and single-shot recovery
let lastBeat = Date.now();
let recovering = false;

worker.addEventListener('message', (e: MessageEvent) => {
  if (e.data.type === 'heartbeat') lastBeat = e.data.at;
  if (e.data.type === 'error') reportToTelemetry(e.data);
});

worker.addEventListener('error', () => recover());

setInterval(() => {
  if (Date.now() - lastBeat > 2000) recover();
}, 1000);

function recover(): void {
  if (recovering) return;
  recovering = true;
  worker.terminate();

  // The old element's surface is gone for good — replace the node itself.
  const fresh = document.createElement('canvas');
  fresh.className = canvas.className;
  canvas.replaceWith(fresh);
  startRenderer(fresh); // transfers control again, from scratch
}

Rebuild cost is real — new worker, new module graph, new textures — so cap recovery attempts and fall back to the main-thread renderer after the second failure rather than looping. The general patterns for this live in Error Handling & Crash Recovery.

The tab goes to the background

Worker requestAnimationFrame is throttled to roughly zero when the page is hidden, exactly like the main-thread version. Animations resume with a large timestamp jump on return, so drive every animation from the delta between frames and clamp it (Math.min(delta, 100)), otherwise the first visible frame teleports the scene. Never use setInterval in the worker to “keep drawing” while hidden — it burns battery and the compositor discards the frames anyway.

WebGL context loss

A GPU driver reset, a tab discard, or exceeding the context cap fires webglcontextlost. You must call preventDefault() on that event or the context is never restorable; then rebuild every shader, buffer, and texture in the webglcontextrestored handler. Treat GPU resources as cache, never as durable state.

Lifecycle states of an OffscreenCanvas render worker A state machine. Initialising leads to Rendering. Rendering moves to Hidden when the page is backgrounded and back when it becomes visible. Rendering moves to Context lost on webglcontextlost and returns after preventDefault and a resource rebuild. A heartbeat timeout moves it to Unresponsive and an uncaught error to Crashed; both lead to Rebuilding, which replaces the canvas node and re-runs Initialising, or after a capped number of retries ends in a main-thread renderer fallback. Hidden tab backgrounded · rAF throttled Initialising transfer in · getContext Rendering worker rAF owns every frame Context lost GPU reset or context cap Unresponsive no heartbeat for 2 s Crashed error / unhandledrejection Rebuilding terminate · replace the node Main-thread fallback same drawScene(), one thread ctx ok page hidden stop scheduling work visible again clamp the delta webglcontextlost preventDefault() restored rebuild every resource heartbeat timeout onerror or rejection terminate() terminate() new <canvas> node · new worker · transfer control again after 2 tries stop retrying
Every arrow out of Rendering needs a handler: the transfer cannot be undone, so recovery always means a fresh <canvas> node and a fresh worker — and a capped retry that ends on the main-thread renderer.

Browser Compatibility

API Chrome Firefox Safari Edge
OffscreenCanvas constructor 69 105 16.4 79
transferControlToOffscreen() 69 105 16.4 79
getContext('2d') in a worker 69 105 16.4 79
getContext('webgl2') in a worker 69 105 16.4 79
self.requestAnimationFrame in a worker 69 105 16.4 79
convertToBlob() 76 105 16.4 79
transferToImageBitmap() 76 105 16.4 79
createImageBitmap() in a worker 50 42 15 79
ResizeObserver devicePixelContentBoxSize 84 92 16.4 84
Module workers (type: 'module') 80 114 15 80

Safari 16.4 (March 2023) was the last engine to land the API; before that release OffscreenCanvas is simply absent from globalThis, so a feature test is enough and no polyfill exists. Because iOS Safari only updates with the operating system, keep the fallback until your own analytics show 16.3 and Firefox 104 have left your traffic.

// main.ts — one gate, two renderers
function supportsOffscreenCanvas(): boolean {
  return (
    'OffscreenCanvas' in globalThis &&
    typeof HTMLCanvasElement.prototype.transferControlToOffscreen === 'function'
  );
}

if (supportsOffscreenCanvas()) {
  startRenderer(canvas); // worker path, described above
} else {
  startMainThreadRenderer(canvas); // same drawScene(), main-thread context
}

Keeping both paths honest is cheaper than it sounds if drawScene(ctx, timestamp) is written against the context interface rather than against a thread: OffscreenCanvasRenderingContext2D and CanvasRenderingContext2D expose the same drawing surface, so one function can serve both. That structure is exactly what Rendering Charts Off the Main Thread builds on, applying this pattern to a live time-series chart with axes, tooltips, and streaming updates.


Going Further

A renderer on a worker thread also owns its own failures. WebGL in Workers and Context Loss covers what a lost context destroys, the one line in the loss handler that decides whether recovery is possible at all, and how to structure resources so they can be rebuilt from plain data rather than re-fetched.

Frequently Asked Questions

Which browsers support OffscreenCanvas?
Chrome 69+, Firefox 105+, Edge 79+, and Safari 16.4+ all ship OffscreenCanvas. Safari was the last major engine to land support, in March 2023 with Safari 16.4. Feature-detect with 'OffscreenCanvas' in globalThis && typeof HTMLCanvasElement.prototype.transferControlToOffscreen === 'function' before transferring control, and keep a main-thread renderer as the fallback path.
Can I use WebGL inside a worker with OffscreenCanvas?
Yes. Call offscreen.getContext('webgl2') (or 'webgl') inside the worker exactly as you would on the main thread. The GPU command queue is still shared with the compositor, but JS execution, state changes, and requestAnimationFrame scheduling all run on the worker thread. The per-page WebGL context limit (typically 8–16) still applies, so a null return from getContext must be handled.
What happens to the DOM canvas after transferControlToOffscreen?
The DOM <canvas> element becomes a display placeholder. Its CSS box and its width/height attributes still control the size on screen, but you can no longer call getContext() on it or draw into it from the main thread — both throw an InvalidStateError. The element still receives pointer, wheel, and keyboard events, so hit-testing stays a main-thread job.
How do I handle canvas resizing with OffscreenCanvas?
Attach a ResizeObserver on the main thread — the API does not exist in a worker scope. When the box changes, post {type:'resize', width, height, dpr} to the worker and set offscreen.width / offscreen.height inside the worker. Multiply by devicePixelRatio for sharp output on HiDPI screens, and reapply ctx.scale(dpr, dpr) because assigning to width or height resets the context state.
Does OffscreenCanvas make rendering faster?
It does not make a single frame cheaper — the same draw calls cost the same. It makes frame delivery reliable: the render loop no longer queues behind main-thread layout, script, or garbage collection, so a 200 ms parse task on the main thread stops eating a dozen frames. If one frame already takes 40 ms of drawing, you still have to reduce the drawing work.

See also