OffscreenCanvas Rendering

OffscreenCanvas moves the entire canvas rendering pipeline off the main thread. This page is part of High-Performance Computation Patterns β€” the broader guide to keeping the UI thread free for input and layout while heavy work runs in background contexts.

The classic symptom is a chart, game, or data-visualization that causes perceptible jank on scroll or interaction. Chrome’s Performance tab shows long paint tasks competing with input handlers in the Main thread track. The root cause is synchronous canvas drawing blocking the 16.67ms frame budget. OffscreenCanvas fixes this by running the requestAnimationFrame render loop entirely inside a dedicated worker, leaving the main thread only responsible for passing data deltas and reacting to user events.

Prerequisites

You need a basic Web Worker setup and familiarity with the Canvas 2D or WebGL API. You should understand Transferable Objects & Zero-Copy because OffscreenCanvas is itself a transferable β€” ownership must be explicitly moved. You also need HTTPS (or localhost) since workers require a secure context.

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.

Step 1 β€” Transfer Canvas Control

Call canvas.transferControlToOffscreen() on the main thread. This is a one-time, irreversible operation that returns an OffscreenCanvas object. The original DOM <canvas> element still controls displayed width and height β€” but you can no longer acquire a rendering context from it.

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

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

// The OffscreenCanvas must be in the transfer list, not just the message payload.
worker.postMessage(
  { type: 'init', canvas: offscreen, dpr: window.devicePixelRatio },
  [offscreen] // transfer ownership β€” offscreen is now detached on this side
);

After this call, offscreen in main.ts is detached β€” its width and height will be 0. The worker now holds the only live reference to the rendering context.

One transfer only

transferControlToOffscreen() throws an InvalidStateError if called more than once on the same canvas element. Attempting to call canvas.getContext() after transferring also throws. There is no way to revoke the transfer and return control to the main thread.

Step 2 β€” Acquire a Rendering Context Inside the Worker

Receive the OffscreenCanvas in the worker’s onmessage handler and call getContext(). Both '2d' and 'webgl2' are supported in workers across all major browsers.

// render-worker.ts
let ctx: OffscreenCanvasRenderingContext2D | null = null;
let dpr = 1;

self.onmessage = (e: MessageEvent) => {
  const { type, canvas, width, height } = e.data as {
    type: string;
    canvas?: OffscreenCanvas;
    dpr?: number;
    width?: number;
    height?: number;
  };

  if (type === 'init' && canvas) {
    dpr = e.data.dpr ?? 1;
    ctx = canvas.getContext('2d');
    scheduleFrame();
  }

  if (type === 'resize' && ctx && width && height) {
    ctx.canvas.width = Math.round(width * dpr);
    ctx.canvas.height = Math.round(height * dpr);
  }
};

function scheduleFrame(): void {
  self.requestAnimationFrame(renderFrame);
}

function renderFrame(timestamp: number): void {
  if (!ctx) return;
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  // draw here...
  self.requestAnimationFrame(renderFrame);
}
Performance

self.requestAnimationFrame inside a worker is driven by the browser's compositor, not by main-thread JavaScript. Even if the main thread is executing a 200ms parse task, the worker's rAF loop continues to fire at 60 fps, maintaining smooth visual output.

Step 3 β€” Run 2D and WebGL Contexts in Workers

2D Context

The OffscreenCanvasRenderingContext2D API mirrors CanvasRenderingContext2D almost exactly. The main differences: canvas.style does not exist (no CSS on off-thread canvases), and drawImage() accepts ImageBitmap (not raw HTMLImageElement). Fetch and decode images with createImageBitmap() or transfer ImageBitmap from the main thread.

// render-worker.ts β€” drawing with 2d context
function drawBar(
  ctx: OffscreenCanvasRenderingContext2D,
  x: number,
  y: number,
  w: number,
  h: number,
  color: string
): void {
  ctx.fillStyle = color;
  ctx.fillRect(x, y, w, h);
}

WebGL2 Context

// render-worker.ts β€” WebGL2 inside a worker
self.onmessage = (e: MessageEvent) => {
  if (e.data.type === 'init') {
    const gl = (e.data.canvas as OffscreenCanvas).getContext('webgl2', {
      antialias: true,
      powerPreference: 'high-performance',
    }) as WebGL2RenderingContext;

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

    gl.clearColor(0, 0, 0, 0);
    self.requestAnimationFrame(() => {
      gl.clear(gl.COLOR_BUFFER_BIT);
      // set up shaders, buffers, draw calls...
    });
  }
};
WebGL context limits

The GPU has a hard cap on simultaneous WebGL contexts (typically 8–16 per page). Transferring an OffscreenCanvas to a worker does not bypass this limit. If you spawn many workers each creating a WebGL context, watch for getContext() returning null β€” handle this gracefully by falling back to a 2d context or a canvas pool.

Step 4 β€” Converting Frames with convertToBlob and transferToImageBitmap

Sometimes you need to read pixels back from the worker β€” for saving frames as PNG, or compositing the result onto another canvas on the main thread.

// render-worker.ts β€” export frame as Blob
async function exportFrame(offscreen: OffscreenCanvas): Promise<void> {
  const blob = await offscreen.convertToBlob({ type: 'image/png', quality: 1 });
  // blob can be posted back β€” Blob is structured-cloneable
  self.postMessage({ type: 'frame-blob', blob });
}

// render-worker.ts β€” export as ImageBitmap (zero-copy)
function exportImageBitmap(offscreen: OffscreenCanvas): void {
  const bitmap: ImageBitmap = offscreen.transferToImageBitmap();
  // Transfer the bitmap back to the main thread β€” zero-copy
  self.postMessage({ type: 'frame-bitmap', bitmap }, [bitmap]);
}

On the main thread, receive the ImageBitmap and render it into a second canvas:

// main.ts β€” receive and display bitmap
worker.onmessage = (e: MessageEvent) => {
  if (e.data.type === 'frame-bitmap') {
    const bitmap: ImageBitmap = e.data.bitmap;
    const displayCtx = displayCanvas.getContext('2d')!;
    displayCtx.drawImage(bitmap, 0, 0);
    bitmap.close(); // release GPU memory
  }
};
Performance

transferToImageBitmap() is sub-millisecond β€” it moves GPU texture ownership without copying pixel data. convertToBlob() involves PNG encoding and typically takes 5–30ms for a 1920Γ—1080 frame. Use bitmap transfer for real-time compositing; reserve blob export for snapshot/save workflows.

Step 5 β€” Resizing and devicePixelRatio Handling

Resize is a main-thread concern (the ResizeObserver API only exists there), but the actual dimension update must happen in the worker.

// main.ts β€” observe and forward resizes
const canvas = document.getElementById('chart') as HTMLCanvasElement;
const worker = new Worker(new URL('./render-worker.ts', import.meta.url), {
  type: 'module',
});

const offscreen = canvas.transferControlToOffscreen();
worker.postMessage(
  { type: 'init', canvas: offscreen, dpr: window.devicePixelRatio },
  [offscreen]
);

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { inlineSize: width, blockSize: height } =
      entry.contentBoxSize[0] ?? entry.contentRect;
    worker.postMessage({
      type: 'resize',
      width: Math.round(width),
      height: Math.round(height),
      dpr: window.devicePixelRatio,
    });
  }
});
ro.observe(canvas);
// render-worker.ts β€” apply resize
let dpr = 1;

self.onmessage = (e: MessageEvent) => {
  if (e.data.type === 'resize' && ctx) {
    dpr = e.data.dpr ?? 1;
    ctx.canvas.width = Math.round(e.data.width * dpr);
    ctx.canvas.height = Math.round(e.data.height * dpr);
    ctx.scale(dpr, dpr); // restore scale after resize resets transform
  }
};
Resize resets the canvas transform

Assigning to canvas.width or canvas.height clears the context state, including any active transform. Always reapply ctx.scale(dpr, dpr) after a resize to maintain HiDPI sharpness. Without this, Retina and HiDPI displays show blurry output.

Data-Transfer Strategy

OffscreenCanvas rendering typically involves two data flows: initial setup (the canvas transfer) and per-frame data updates (chart values, image pixels, etc.).

Transfer type Cost Use when
OffscreenCanvas in transfer list Sub-ms, one-time Always β€” required for setup
Small data objects (structured clone) < 0.1 ms for < 10 KB Chart data points, config updates
ArrayBuffer transfer Sub-ms regardless of size Pixel buffers, audio data, large typed arrays
ImageBitmap transfer Sub-ms Decoded images, frame export
convertToBlob() 5–30ms (encoding) PNG/JPEG export only

For streaming chart data, post compact typed arrays rather than JSON. A Float32Array of 1000 data points is 4 KB. The same data as a JSON string is 10–20 KB and incurs structured-clone cost. Transfer the ArrayBuffer and reconstruct the view in the worker:

// main.ts β€” send data as transferable Float32Array
function sendChartData(values: number[]): void {
  const buffer = new Float32Array(values).buffer;
  worker.postMessage({ type: 'data', buffer }, [buffer]);
}

// render-worker.ts β€” receive
self.onmessage = (e: MessageEvent) => {
  if (e.data.type === 'data') {
    const values = new Float32Array(e.data.buffer);
    updateChartData(values);
  }
};

This integrates naturally with the Image Processing in Workers pattern, where raw pixel ArrayBuffers are transferred in both directions with zero copy overhead.

Verification and Measurement

Chrome Performance Tab

  1. Open DevTools β†’ Performance β†’ record 3–5 seconds of animation.
  2. Expand the Worker thread track. Paint tasks and requestAnimationFrame callbacks should appear there, not in the Main thread track.
  3. Compare the Frames panel. With rendering on the main thread, jank produces dropped frames visible as tall red bars. After moving to OffscreenCanvas, the frame lane should be consistently green at 60 fps.

performance.now() Frame Budget

// render-worker.ts β€” measure per-frame cost
let lastFrame = 0;

function renderFrame(timestamp: number): void {
  const delta = timestamp - lastFrame;
  lastFrame = timestamp;

  if (delta > 20) {
    // Over budget β€” log for analysis
    self.postMessage({ type: 'perf-warn', frameMs: delta });
  }

  if (!ctx) return;
  const t0 = performance.now();
  // draw...
  const drawMs = performance.now() - t0;
  if (drawMs > 8) {
    self.postMessage({ type: 'slow-draw', drawMs });
  }

  self.requestAnimationFrame(renderFrame);
}

A well-optimized OffscreenCanvas render loop should keep individual draw calls under 4–8ms to maintain a 60fps frame budget on mid-range hardware. Complex WebGL scenes may budget up to 12ms on dedicated GPU threads.

Failure Modes and Error Handling

Context Acquisition Failure

getContext() can return null inside a worker for several reasons: the canvas was already transferred elsewhere, WebGL context limits are exhausted, or the browser has disabled hardware acceleration. Always null-check and report back:

// render-worker.ts
self.onmessage = (e: MessageEvent) => {
  if (e.data.type === 'init') {
    const canvas = e.data.canvas as OffscreenCanvas;
    const ctx = canvas.getContext('2d');
    if (!ctx) {
      self.postMessage({ type: 'error', message: 'Failed to acquire 2d context' });
      return;
    }
    // proceed...
  }
};

self.onerror = (event: ErrorEvent) => {
  self.postMessage({
    type: 'error',
    message: event.message,
    filename: event.filename,
    lineno: event.lineno,
  });
};

Worker Crash Recovery

If the worker crashes (unhandled exception), the main thread’s worker.onerror fires but the canvas goes blank β€” there is no automatic fallback rendering. Implement a watchdog on the main thread:

// main.ts β€” simple watchdog
let lastHeartbeat = Date.now();
const TIMEOUT_MS = 2000;

worker.onmessage = (e: MessageEvent) => {
  if (e.data.type === 'heartbeat') lastHeartbeat = Date.now();
  if (e.data.type === 'error') console.error('[render-worker]', e.data.message);
};

worker.onerror = (e: ErrorEvent) => {
  console.error('Worker crashed:', e.message);
  // Optionally restart β€” but you cannot reclaim the transferred canvas
  // so a restart requires a new <canvas> element in the DOM
};

setInterval(() => {
  if (Date.now() - lastHeartbeat > TIMEOUT_MS) {
    console.warn('Render worker unresponsive β€” consider restarting');
  }
}, 1000);

Note that once a canvas has been transferred, a worker restart requires replacing the DOM <canvas> element and creating a fresh worker. Plan for this in long-running applications like dashboards.

Input Events

OffscreenCanvas removes rendering from the main thread but input events (click, mousemove, wheel) remain on the DOM canvas. Forward hit-test coordinates to the worker as needed:

// main.ts β€” forward pointer events
canvas.addEventListener('pointermove', (e: PointerEvent) => {
  const rect = canvas.getBoundingClientRect();
  worker.postMessage({
    type: 'pointer',
    x: (e.clientX - rect.left) * window.devicePixelRatio,
    y: (e.clientY - rect.top) * window.devicePixelRatio,
  });
});

Browser Compatibility

Feature Chrome Firefox Safari Edge
OffscreenCanvas constructor 69 105 16.4 79
transferControlToOffscreen() 69 105 16.4 79
getContext('2d') in worker 69 105 16.4 79
getContext('webgl') in worker 69 105 16.4 79
getContext('webgl2') in worker 69 105 16.4 79
self.requestAnimationFrame in worker 69 105 16.4 79
convertToBlob() 76 105 16.4 79
transferToImageBitmap() 76 105 16.4 79

Safari shipped OffscreenCanvas in Safari 16.4 (March 2023). Before that release, any Safari version silently omits OffscreenCanvas from globalThis. Use a feature-detect and fall back to main-thread canvas for users on older iOS/macOS:

// main.ts β€” feature detection
function supportsOffscreenCanvas(): boolean {
  return 'OffscreenCanvas' in globalThis &&
    typeof HTMLCanvasElement.prototype.transferControlToOffscreen === 'function';
}

if (supportsOffscreenCanvas()) {
  initWorkerRenderer(canvas);
} else {
  initMainThreadRenderer(canvas); // fallback
}

Global usage of Safari 16.4+ covers the vast majority of active Safari users as of mid-2026. The fallback path is primarily for users on iOS devices that have not updated their system browser, which updates only with iOS itself.

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. Check 'OffscreenCanvas' in globalThis at runtime before transferring control.
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 shared with the compositor, but JS execution and requestAnimationFrame scheduling run entirely in the worker thread.
What happens to the DOM canvas after transferControlToOffscreen?
The DOM <canvas> element becomes a placeholder β€” its width and height attributes still control the displayed size, but you can no longer call getContext() or draw into it from the main thread. Attempting to do so throws an InvalidStateError.
How do I handle canvas resizing with OffscreenCanvas?
Listen for ResizeObserver on the main thread. When the canvas size changes, send a {type:'resize', width, height} message to the worker, and inside the worker set offscreen.width and offscreen.height to match. Multiply by devicePixelRatio before sending to get sharp output on HiDPI screens.

See also