OffscreenCanvas & Worker Rendering
The 60fps Illusion
You built a data visualization that draws 50,000 data points on a canvas. It looks great. Then the user opens a dropdown menu and it stutters. They type in a search box and keystrokes lag by 200ms. The visualization is rendering at a smooth 60fps, but it's consuming so much main thread time that everything else suffers.
// This monopolizes the main thread during every frame
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < 50_000; i++) {
const x = data[i].x * scaleX;
const y = data[i].y * scaleY;
ctx.fillRect(x, y, 2, 2);
}
requestAnimationFrame(render);
}
Each frame takes 8-12ms of main thread time. That leaves 4-8ms for input processing, layout, paint, and everything else. On a slightly slower device, you're over budget. The visualization looks smooth but the rest of the app feels broken.
OffscreenCanvas solves this by moving the entire rendering pipeline to a worker. The canvas still displays on the main thread's page, but the drawing commands execute on a separate thread.
Think of OffscreenCanvas like a TV studio setup. The main thread is the live broadcast — it handles the teleprompter (input), the lighting (layout), and the cameras (paint). The worker is a separate editing suite connected to the same TV screen via a video feed. The editor renders complex graphics in their suite without slowing down the live broadcast. The audience (user) sees both — the live UI and the rendered graphics — seamlessly composited by the browser.
Two Ways to Use OffscreenCanvas
Path 1: Transfer from an existing canvas
The most common pattern. You have a <canvas> element in your HTML and transfer its rendering control to a worker:
// main.js
const canvas = document.getElementById('chart');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('/workers/renderer.js', { type: 'module' });
worker.postMessage({ canvas: offscreen }, [offscreen]);
// The main thread can no longer draw to this canvas
// All rendering happens in the worker
// renderer.js
let ctx;
let width;
let height;
self.onmessage = (event) => {
if (event.data.canvas) {
const canvas = event.data.canvas;
ctx = canvas.getContext('2d');
width = canvas.width;
height = canvas.height;
startRendering();
}
};
function startRendering() {
function frame() {
ctx.clearRect(0, 0, width, height);
drawVisualization(ctx);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
After transferControlToOffscreen(), the main thread loses all ability to draw to that canvas. The worker owns it exclusively. The browser composites the worker's rendering output into the page automatically.
Path 2: Create OffscreenCanvas directly in a worker
You can create an OffscreenCanvas inside a worker without any DOM element. This is useful for off-screen image processing where you don't need to display the result immediately:
// worker.js — no DOM canvas needed
const canvas = new OffscreenCanvas(1920, 1080);
const ctx = canvas.getContext('2d');
self.onmessage = async (event) => {
const bitmap = event.data.image;
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, 1920, 1080);
applyFilter(imageData);
ctx.putImageData(imageData, 0, 0);
const result = canvas.transferToImageBitmap();
self.postMessage({ processed: result }, [result]);
};
The worker creates the canvas, processes an image, and transfers the result back as an ImageBitmap. The main thread can then draw it to a visible canvas or display it directly using a bitmaprenderer context.
requestAnimationFrame in Workers
Workers have their own requestAnimationFrame — it's tied to the display refresh rate just like the main thread's version. This lets you build smooth animations entirely in the worker:
// renderer.js
let canvas;
let ctx;
let time = 0;
self.onmessage = (event) => {
canvas = event.data.canvas;
ctx = canvas.getContext('2d');
animate();
};
function animate() {
time += 0.016;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < 1000; i++) {
const x = Math.sin(time + i * 0.01) * 200 + canvas.width / 2;
const y = Math.cos(time + i * 0.013) * 200 + canvas.height / 2;
ctx.fillStyle = `hsl(${(i * 0.36 + time * 50) % 360}, 70%, 60%)`;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(animate);
}
The main thread is completely free. No JavaScript runs on the main thread during this animation — all 1000 animated circles are drawn by the worker. Input events, layout, and paint all happen without contention.
Worker requestAnimationFrame only fires when the canvas is visible in the viewport. If the user scrolls the canvas off-screen or switches tabs, rAF pauses — just like on the main thread. This is correct behavior (no wasted work), but if your worker relies on rAF for non-rendering tasks (like game logic), you need a fallback timer.
WebGL in Workers
OffscreenCanvas supports WebGL and WebGL2 contexts, enabling GPU-accelerated rendering from a worker:
// worker.js — WebGL in a worker
self.onmessage = (event) => {
const canvas = event.data.canvas;
const gl = canvas.getContext('webgl2');
if (!gl) {
self.postMessage({ error: 'WebGL2 not available in worker' });
return;
}
gl.clearColor(0.0, 0.0, 0.0, 1.0);
function render() {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
drawScene(gl);
requestAnimationFrame(render);
}
requestAnimationFrame(render);
};
This is particularly powerful for data visualization libraries, 3D renderers, and map engines that do heavy GPU work. The shader compilation, buffer uploads, and draw calls all happen on the worker thread.
Handling Canvas Resize
Canvas resize is tricky with OffscreenCanvas because ResizeObserver only works on the main thread. You need to relay resize events:
// main.js
const canvas = document.getElementById('chart');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('/workers/renderer.js', { type: 'module' });
worker.postMessage({ type: 'INIT', canvas: offscreen }, [offscreen]);
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
const dpr = window.devicePixelRatio || 1;
const width = Math.round(entry.contentBoxSize[0].inlineSize * dpr);
const height = Math.round(entry.contentBoxSize[0].blockSize * dpr);
worker.postMessage({ type: 'RESIZE', width, height, dpr });
});
observer.observe(canvas);
// renderer.js
let canvas;
let ctx;
let dpr = 1;
self.onmessage = (event) => {
if (event.data.type === 'INIT') {
canvas = event.data.canvas;
ctx = canvas.getContext('2d');
return;
}
if (event.data.type === 'RESIZE') {
canvas.width = event.data.width;
canvas.height = event.data.height;
dpr = event.data.dpr;
ctx.scale(dpr, dpr);
redraw();
}
};
Always account for devicePixelRatio when sizing OffscreenCanvas. On a 2x Retina display, a CSS-pixel canvas of 800x600 should have an actual resolution of 1600x1200 to avoid blurry rendering. Send the DPR from the main thread since workers do not have access to window.devicePixelRatio.
Production Pattern: Chart Rendering Pipeline
Here's how a real charting library would use OffscreenCanvas:
// main.js
class OffscreenChart {
#worker;
#resizeObserver;
constructor(canvasElement) {
const offscreen = canvasElement.transferControlToOffscreen();
this.#worker = new Worker('/workers/chart.js', { type: 'module' });
this.#worker.postMessage(
{ type: 'INIT', canvas: offscreen },
[offscreen]
);
this.#resizeObserver = new ResizeObserver((entries) => {
const { inlineSize, blockSize } = entries[0].contentBoxSize[0];
const dpr = devicePixelRatio;
this.#worker.postMessage({
type: 'RESIZE',
width: Math.round(inlineSize * dpr),
height: Math.round(blockSize * dpr),
dpr,
});
});
this.#resizeObserver.observe(canvasElement);
}
setData(data) {
if (data.buffer instanceof ArrayBuffer) {
const copy = data.buffer.slice(0);
this.#worker.postMessage(
{ type: 'DATA', buffer: copy },
[copy]
);
} else {
this.#worker.postMessage({ type: 'DATA', values: data });
}
}
destroy() {
this.#resizeObserver.disconnect();
this.#worker.terminate();
}
}
// chart.js (worker)
let canvas, ctx, dpr, dataPoints;
self.onmessage = (event) => {
const { type } = event.data;
if (type === 'INIT') {
canvas = event.data.canvas;
ctx = canvas.getContext('2d');
}
if (type === 'RESIZE') {
canvas.width = event.data.width;
canvas.height = event.data.height;
dpr = event.data.dpr;
scheduleRender();
}
if (type === 'DATA') {
dataPoints = event.data.buffer
? new Float64Array(event.data.buffer)
: event.data.values;
scheduleRender();
}
};
let renderScheduled = false;
function scheduleRender() {
if (renderScheduled) return;
renderScheduled = true;
requestAnimationFrame(() => {
renderScheduled = false;
render();
});
}
function render() {
if (!ctx || !dataPoints) return;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.save();
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
ctx.beginPath();
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 1.5;
const step = w / (dataPoints.length - 1);
const max = Math.max(...dataPoints);
const min = Math.min(...dataPoints);
const range = max - min || 1;
for (let i = 0; i < dataPoints.length; i++) {
const x = i * step;
const y = h - ((dataPoints[i] - min) / range) * h * 0.9 - h * 0.05;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
}
Browser Support and Fallbacks
OffscreenCanvas is supported in Chrome 69+, Firefox 105+, Safari 16.4+, and Edge 79+. The transferControlToOffscreen method has the same support. For older browsers, fall back to main-thread rendering:
function createRenderer(canvasElement) {
if (typeof canvasElement.transferControlToOffscreen === 'function') {
return new OffscreenRenderer(canvasElement);
}
return new MainThreadRenderer(canvasElement);
}
| What developers do | What they should do |
|---|---|
| Trying to access the DOM from a worker to read canvas CSS dimensions Workers have no DOM access. ResizeObserver runs on the main thread and can relay width, height, and devicePixelRatio to the worker. The worker uses these values to size the OffscreenCanvas correctly. | Observe size changes on the main thread with ResizeObserver and send dimensions to the worker via postMessage |
| Not accounting for devicePixelRatio in OffscreenCanvas rendering Without DPR scaling, canvas content appears blurry on high-density displays (Retina, 4K). The worker does not have access to window.devicePixelRatio — the main thread must send it. | Set canvas resolution to CSS size times DPR, then apply ctx.scale(dpr, dpr) for crisp rendering |
| Creating a new OffscreenCanvas per frame for off-screen processing OffscreenCanvas creation involves memory allocation. Creating one every frame adds GC pressure and allocation overhead. Create once, clear and redraw each frame. | Create the OffscreenCanvas once and reuse it across frames |
| Using OffscreenCanvas for simple static charts that render once If a chart renders once and takes 5ms, the worker setup overhead (creation, message passing, resize handling) exceeds the benefit. Use OffscreenCanvas when rendering is continuous (animations) or per-frame cost exceeds ~10ms. | OffscreenCanvas is worth the complexity only for animated, interactive, or data-heavy visualizations that would block the main thread |
Challenge: Responsive Particle System
Try to solve it before peeking at the answer.
// Build a particle system that:
// 1. Renders 10,000 particles on OffscreenCanvas in a worker
// 2. Particles respond to mouse position (sent from main thread)
// 3. Handles canvas resize with correct DPR
// 4. Falls back to main-thread rendering if OffscreenCanvas
// is not supported
//
// Particles should drift randomly and be attracted toward
// the mouse cursor position.
// main.js
// Your code: setup, mouse tracking, resize handling
// worker.js
// Your code: particle simulation + renderingKey Rules
- 1transferControlToOffscreen() gives a worker exclusive rendering control over a canvas. The main thread can no longer draw to it.
- 2Workers have their own requestAnimationFrame, tied to the display refresh rate. Use it for smooth worker-driven animations.
- 3OffscreenCanvas supports 2d, webgl, and webgl2 contexts. All work inside Web Workers.
- 4Send resize events and devicePixelRatio from the main thread via postMessage — workers have no DOM access and no access to window.devicePixelRatio.
- 5Use OffscreenCanvas for continuous rendering or expensive per-frame work (over 10ms). For simple one-time renders, the setup overhead outweighs the benefit.