Transferring Canvas Control to a Worker

canvas.transferControlToOffscreen() is the single API call that moves rendering work off the main thread. This page walks through a minimal, complete example and explains every constraint in detail. It sits within OffscreenCanvas Rendering, which is part of High-Performance Computation Patterns.

Minimal Reproducible Example

Two files — main.ts on the page and render-worker.ts in the worker — are all you need to get a canvas rendering entirely off the main thread.

HTML

<!-- index.html -->
<canvas id="chart" width="800" height="400"></canvas>
<script type="module" src="./main.ts"></script>

Main Thread

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

if (!('OffscreenCanvas' in globalThis)) {
  throw new Error('OffscreenCanvas is not supported in this browser.');
}

// Step 1: Transfer control. This is a one-way, irreversible operation.
const offscreen: OffscreenCanvas = canvas.transferControlToOffscreen();

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

// Step 3: Post the OffscreenCanvas. It MUST be in the transfer list.
worker.postMessage(
  {
    type: 'init',
    canvas: offscreen,
    width: canvas.width,
    height: canvas.height,
    dpr: window.devicePixelRatio,
  },
  [offscreen] // transfer list — ownership moves, source is now detached
);

// Step 4: Forward pointer events to the worker (input stays on main thread).
canvas.addEventListener('pointermove', (e: PointerEvent) => {
  const rect = canvas.getBoundingClientRect();
  worker.postMessage({
    type: 'pointer',
    x: e.clientX - rect.left,
    y: e.clientY - rect.top,
  });
});

// Step 5: Forward resize events.
const ro = new ResizeObserver(() => {
  worker.postMessage({
    type: 'resize',
    width: canvas.clientWidth,
    height: canvas.clientHeight,
    dpr: window.devicePixelRatio,
  });
});
ro.observe(canvas);

worker.onerror = (e: ErrorEvent) => {
  console.error('Render worker error:', e.message, e.filename, e.lineno);
};

Worker

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

interface InitMessage {
  type: 'init';
  canvas: OffscreenCanvas;
  width: number;
  height: number;
  dpr: number;
}

interface ResizeMessage {
  type: 'resize';
  width: number;
  height: number;
  dpr: number;
}

interface PointerMessage {
  type: 'pointer';
  x: number;
  y: number;
}

type WorkerMessage = InitMessage | ResizeMessage | PointerMessage;

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

  if (msg.type === 'init') {
    dpr = msg.dpr;
    frameWidth = Math.round(msg.width * dpr);
    frameHeight = Math.round(msg.height * dpr);

    msg.canvas.width = frameWidth;
    msg.canvas.height = frameHeight;

    ctx = msg.canvas.getContext('2d');
    if (!ctx) {
      self.postMessage({ type: 'error', message: 'Failed to get 2d context' });
      return;
    }

    ctx.scale(dpr, dpr);
    self.requestAnimationFrame(renderFrame);
  }

  if (msg.type === 'resize') {
    dpr = msg.dpr;
    frameWidth = Math.round(msg.width * dpr);
    frameHeight = Math.round(msg.height * dpr);

    if (ctx) {
      ctx.canvas.width = frameWidth;
      ctx.canvas.height = frameHeight;
      ctx.scale(dpr, dpr); // resize clears the transform — restore it
    }
  }
};

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

  const cssWidth = frameWidth / dpr;
  const cssHeight = frameHeight / dpr;

  ctx.clearRect(0, 0, cssWidth, cssHeight);

  // Example: animated gradient bar
  const progress = (Math.sin(timestamp / 800) + 1) / 2;
  ctx.fillStyle = '#3b82f6';
  ctx.fillRect(40, 40, (cssWidth - 80) * progress, 60);

  ctx.fillStyle = '#1e293b';
  ctx.font = '14px system-ui, sans-serif';
  ctx.fillText(`timestamp: ${timestamp.toFixed(0)} ms`, 40, 130);

  self.requestAnimationFrame(renderFrame);
}

Line-by-Line Walkthrough

canvas.transferControlToOffscreen() — returns an OffscreenCanvas whose dimensions match the width/height attributes of the original <canvas>. The DOM element itself continues to exist and sizes the on-page space, but it is now purely a display surface driven by the compositor.

worker.postMessage({canvas: offscreen}, [offscreen]) — the second argument is the transfer list. Including offscreen there tells the browser to move ownership rather than copy. After this call, offscreen.width and offscreen.height in main.ts are both 0 — the object is detached. Any subsequent access to its methods throws InvalidStateError.

msg.canvas.getContext('2d') inside the worker — works identically to HTMLCanvasElement.getContext('2d') on the main thread. The OffscreenCanvasRenderingContext2D supports the complete Canvas 2D API: paths, fills, strokes, gradients, transforms, text, and drawImage (with ImageBitmap sources).

self.requestAnimationFrame(renderFrame) — this is DedicatedWorkerGlobalScope.requestAnimationFrame. It is scheduled by the browser compositor at the display refresh rate, just like its main-thread counterpart. The callback receives a DOMHighResTimeStamp for animation timing.

ctx.canvas.width = frameWidth in the resize handler — assigning to width or height clears the context state (pixels, transform, fill/stroke styles). Always reassign drawing state and transforms immediately after.

Gotchas and Edge Cases

1. You Can Only Transfer Once

transferControlToOffscreen() is a single-use operation per canvas element. Calling it a second time throws InvalidStateError. If your worker crashes and you need to restart it, you must create a new <canvas> element in the DOM, transfer its control to a fresh worker, and tear down the old one. Build the restart path into dashboards and long-lived visualizations.

2. The DOM Canvas Becomes a Placeholder

After transfer, the DOM <canvas> element continues to occupy space on the page and the browser composites the worker’s rendered output into it automatically. However, from the main thread’s perspective, the canvas has no context. Calling canvas.getContext('2d') returns null (Chrome/Firefox) or throws (Safari). Never attempt to draw from the main thread after transfer.

3. Input Events Stay on the Main Thread

OffscreenCanvas has no DOM, so mouse, touch, and keyboard events are not accessible inside the worker. You must listen on the DOM canvas in main.ts and forward coordinates via postMessage. For high-frequency events like pointermove on a 120Hz display, throttle to one message per rAF tick to avoid flooding the worker’s message queue:

// main.ts — throttle pointermove to one message per frame
let pendingPointer: { x: number; y: number } | null = null;

canvas.addEventListener('pointermove', (e: PointerEvent) => {
  const rect = canvas.getBoundingClientRect();
  pendingPointer = { x: e.clientX - rect.left, y: e.clientY - rect.top };
});

function flushPointer(): void {
  if (pendingPointer) {
    worker.postMessage({ type: 'pointer', ...pendingPointer });
    pendingPointer = null;
  }
  requestAnimationFrame(flushPointer);
}
requestAnimationFrame(flushPointer);

4. Sizing: CSS vs Attribute vs devicePixelRatio

Three distinct size concepts must stay synchronized:

  • CSS size (canvas.style.width/height or layout): controls the physical pixels the element occupies on screen.
  • Canvas attribute size (canvas.width/height or offscreen.width/height): the internal pixel buffer resolution.
  • devicePixelRatio (DPR): the ratio between physical display pixels and CSS pixels (commonly 2× on Retina).

Set offscreen.width = cssWidth * dpr and offscreen.height = cssHeight * dpr, then call ctx.scale(dpr, dpr) so your drawing coordinates match CSS pixels. Omitting DPR scaling produces blurry output on HiDPI displays. Omitting the ctx.scale after setting dimensions produces output scaled to 1× CSS pixels regardless of DPR.

Performance Rule of Thumb

Moving a 60fps animation from the main thread to an OffscreenCanvas worker typically reduces main-thread frame time by 4–12ms per frame — the equivalent of removing a mid-sized requestAnimationFrame callback. On a page with a complex UI (virtualized lists, CSS animations, frequent DOM mutations), this margin is the difference between consistently smooth 60fps and sporadic jank spikes. The gain is proportional to how complex the drawing loop is: simple status-bar charts save little; WebGL particle systems or large canvas data visualizations save substantially more.

For the complete implementation pattern — including streaming data updates, live charting, and chart-specific optimizations — see Rendering Charts Off the Main Thread.

Frequently Asked Questions

Why does postMessage(offscreen) fail if I don't include the transfer list?
OffscreenCanvas is a transferable — it must be listed in the second argument of postMessage, not just passed in the message payload. Omitting the transfer list causes the browser to attempt a structured clone, which throws a DataCloneError because OffscreenCanvas is not cloneable. Always write postMessage({canvas: offscreen}, [offscreen]).
Can I call transferControlToOffscreen on a canvas that already has a 2D context?
No. If you have already called canvas.getContext(‘2d’) or any other context type on the DOM canvas, transferControlToOffscreen() throws an InvalidStateError. You must transfer control before acquiring any context on the main thread.

See also