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.
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.
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.
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);
}
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...
});
}
};
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
}
};
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
}
};
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
- Open DevTools β Performance β record 3β5 seconds of animation.
- Expand the Worker thread track. Paint tasks and
requestAnimationFramecallbacks should appear there, not in the Main thread track. - 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.