Transferring Canvas Control to a Worker

canvas.transferControlToOffscreen() is the single API call that hands a page’s <canvas> element to a worker thread — after it returns, every pixel the user sees is painted from code that never touches the main thread.

This is the entry point to OffscreenCanvas Rendering within High-Performance Computation Patterns, and it is worth being precise about what the call does: it does not copy the canvas, and it does not give the worker a second canvas. It moves ownership of the element’s rendering surface, exactly the way transferable objects move ownership of an ArrayBuffer. The DOM element stays where it is and keeps its layout box, but it becomes a passive display surface fed by the compositor, and the main thread permanently loses the ability to draw into it.

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.clientWidth,
    height: canvas.clientHeight,
    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. ResizeObserver does not exist in a worker.
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);
}
Ownership of the canvas surface after transferControlToOffscreen() The DOM canvas element yields an OffscreenCanvas handle, which crosses the thread boundary inside a postMessage transfer list. On the main thread the handle is left detached with width zero; in the worker it becomes the surface that getContext and requestAnimationFrame draw into. Pointer and resize events are still observed on the page and posted in, and the compositor presents the worker's frames back into the element's layout box. Main thread thread boundary Worker thread <canvas id="chart"> layout box · event target no context acquired yet OffscreenCanvas handle 300×150 unless width/height were set detached — offscreen.width === 0 any further use throws InvalidStateError ResizeObserver + pointer listeners still bound to the DOM element coalesced to one message per frame OffscreenCanvas — worker owns it width = cssWidth × dpr ctx = canvas.getContext('2d') image sources must be ImageBitmap self.requestAnimationFrame(draw) scheduled by the compositor not by the page's event loop transferControlToOffscreen() same object, now dead postMessage({ canvas: offscreen }, [offscreen]) ownership moves — nothing is copied pointer + resize messages the worker has no DOM and no ResizeObserver the compositor presents the worker's frames in the element's layout box
The element never moves and never redraws itself again: transferControlToOffscreen() yields a handle, postMessage's transfer list moves that handle's ownership across the boundary, and the main thread is left holding a detached stub while the compositor keeps presenting whatever the worker paints.

Line-by-Line Walkthrough

const offscreen: OffscreenCanvas = canvas.transferControlToOffscreen();

Returns an OffscreenCanvas whose initial dimensions match the width/height attributes of the original element (300×150 if you never set them — a classic source of “why is my chart squashed”). The DOM element continues to exist and continues to size the on-page space, but it is now a presentation surface driven by the compositor rather than something you can draw into. The call is valid exactly once per element, and only while the element has no rendering context.

worker.postMessage({ type: 'init', canvas: offscreen, ... }, [offscreen]);

The second argument is the transfer list, and this is the line people get wrong. Naming offscreen there tells the browser to move ownership instead of attempting a copy; leave it out and the structured clone algorithm runs, discovers a type it cannot serialize, and throws DataCloneError. After a successful transfer, offscreen.width and offscreen.height read 0 on the main thread and any further use of the object throws InvalidStateError — the handle is detached, exactly like a transferred ArrayBuffer.

ctx = msg.canvas.getContext('2d');

Inside the worker this behaves identically to HTMLCanvasElement.getContext('2d'). OffscreenCanvasRenderingContext2D implements the complete Canvas 2D surface — paths, fills, strokes, gradients, patterns, transforms, clipping, text metrics and drawImage — with one substitution: image sources must be ImageBitmap, OffscreenCanvas or VideoFrame rather than DOM <img>/<video> elements, because those types do not exist in a worker. Decode with createImageBitmap(), which is available in workers and is itself transferable; the mechanics are covered in using transferable objects for canvas image data.

self.requestAnimationFrame(renderFrame);

This is DedicatedWorkerGlobalScope.requestAnimationFrame, not a shim. The compositor schedules it at the display refresh rate and hands the callback a DOMHighResTimeStamp on the same clock as the main thread’s version. Because it is driven by the compositor rather than by the page’s event loop, a 200 ms task on the main thread no longer delays a frame — that decoupling is the entire point of the exercise.

ctx.canvas.width = frameWidth;

Assigning to width or height — on a DOM canvas or an OffscreenCanvas — resets the surface: pixels are cleared and the context state (transform, fill and stroke styles, line width, font, clip) returns to defaults. Reapply everything that matters immediately afterwards. In the example that means the ctx.scale(dpr, dpr) on the line below; in real code it usually means calling a small applyContextDefaults(ctx) helper from both the init and resize branches.

Feature Detection and the Fallback Path

Every current engine ships the API — Chrome 69+, Edge 79+, Firefox 105+, Safari 16.4+ — but detection is still worth the four lines, because the failure mode without it is a blank rectangle rather than a degraded chart. Test for the method on the prototype, not just for the global:

// canvas-renderer.ts — one entry point, two implementations
export function mountRenderer(canvas: HTMLCanvasElement): () => void {
  const supported =
    'OffscreenCanvas' in globalThis &&
    typeof HTMLCanvasElement.prototype.transferControlToOffscreen === 'function';

  if (!supported) {
    return mountMainThreadRenderer(canvas); // same draw code, main-thread rAF
  }

  const offscreen = canvas.transferControlToOffscreen();
  const worker = new Worker(new URL('./render-worker.ts', import.meta.url), {
    type: 'module',
  });
  worker.postMessage(
    { type: 'init', canvas: offscreen, dpr: devicePixelRatio,
      width: canvas.clientWidth, height: canvas.clientHeight },
    [offscreen],
  );
  return () => worker.terminate();          // teardown handle for the caller
}

Keeping the draw function in a module imported by both the worker and the fallback means one implementation of the visuals and two schedulers. Note that new URL('./render-worker.ts', import.meta.url) is not decoration: it is the form bundlers statically recognise, and bundling module workers with Vite and webpack explains why a string path silently ships a 404 in production.

Choosing the render path, and recovering from a dead worker A canvas that already owns a rendering context cannot be transferred, so a fresh element must be created. If transferControlToOffscreen is missing, the same draw module runs on a main-thread requestAnimationFrame loop instead. Otherwise the element is transferred once and a module worker is spawned. Because transfer is single-use, restarting a dead render worker means swapping in a new canvas element and re-entering the transfer step. Choosing the render path — and the one-way constraint on it canvas already has a rendering context? create a fresh <canvas> you own transfer never works on a used element transferControlToOffscreen on the prototype? main-thread renderer same draw module, page-side rAF transfer, then spawn the worker postMessage({ canvas }, [offscreen]) worker died or needs a restart? swap in a new <canvas> element then transfer that one to the new worker yes no no yes valid exactly once per element — a second call throws InvalidStateError yes the spent element cannot be reused — re-enter here no — the worker keeps this canvas for the rest of its life
Two questions decide the path, and the third decides recovery: because transferControlToOffscreen() is single-use, restarting a render worker is an element swap, not just a new Worker().

Gotchas and Edge Cases

1. You Can Only Transfer Once

transferControlToOffscreen() is single-use per canvas element; a second call throws InvalidStateError, and so does calling it on an element whose context you already acquired. This makes worker restart a DOM problem rather than a worker problem: if the render worker dies, you cannot simply spawn a replacement and hand it the same canvas. The recovery path is to remove the old <canvas>, insert a fresh one, transfer its control to the new worker, and terminate the old one. Dashboards and long-lived visualizations should build that path deliberately — the same lifecycle discipline described in handling worker termination gracefully in SPAs applies, with the element swap added.

2. The DOM Canvas Becomes a Placeholder

After transfer the element still occupies layout space, still receives pointer, wheel and keyboard events, and still has the worker’s output composited into it automatically — but it has no context of its own. canvas.getContext('2d') throws InvalidStateError in current Chrome and Firefox and behaves inconsistently in older engines, and canvas.toDataURL() / canvas.getContext('2d')?.getImageData() are equally unavailable. Anything that needs pixels back — an “export PNG” button, a visual regression test — must ask the worker, which can call canvas.convertToBlob() on its OffscreenCanvas and post the resulting Blob back.

3. Input Events Stay on the Main Thread

A worker has no DOM, so hit-testing is a message-passing problem. Listen on the DOM canvas, convert client coordinates to canvas-local coordinates with getBoundingClientRect(), and post them. High-frequency streams are the trap: pointermove on a 120 Hz trackpad, or a wheel handler, can emit far more events than there are frames, and because postMessage never blocks, the worker’s message queue grows until the renderer is chasing input from several frames ago. Coalesce to one message per frame:

// 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);

Keeping only the newest sample is the right policy for hover and drag, where stale positions have no value. Gestures that need every sample — freehand drawing, velocity-based inertia — should batch the coalesced events from PointerEvent.getCoalescedEvents() into a single message instead of dropping them.

4. Sizing: CSS vs Attribute vs devicePixelRatio

Three distinct size concepts must stay synchronized, and confusing any two of them is the most common reason worker-rendered canvases look wrong:

  • CSS size (canvas.style.width/height, or whatever layout computes): the physical space the element occupies on screen.
  • Buffer size (canvas.width/height before transfer, offscreen.width/height after): the resolution of the pixel buffer being drawn into.
  • devicePixelRatio: the ratio of device pixels to CSS pixels — 2 on most Retina displays, 1.5, 1.75 and 2.625 on common Android devices, and a fractional, changing value under browser zoom.

Set offscreen.width = cssWidth * dpr and offscreen.height = cssHeight * dpr, then ctx.scale(dpr, dpr) so drawing coordinates stay in CSS pixels. Skip the multiplication and HiDPI output is visibly soft; skip the scale and everything renders at 1× in the corner of a larger buffer. Because DPR changes when a window moves between monitors or the user zooms, re-read window.devicePixelRatio inside the ResizeObserver callback rather than caching it at startup.

CSS size, buffer size and drawing coordinates at devicePixelRatio 2 Layout gives the element a 400 by 200 CSS pixel box. Multiplying by devicePixelRatio gives an 800 by 400 pixel buffer, and ctx.scale(dpr, dpr) puts drawing coordinates back into CSS pixels. Leaving the buffer at CSS size stretches one canvas pixel across two device pixels and looks soft; skipping the scale call draws everything at one times in the top-left quarter of the canvas. Three sizes, one element — keep them in sync (dpr = 2 here) CSS box what layout gives the element 400 × 200 css px canvas.clientWidth / Height pixel buffer the surface you draw into 800 × 400 device px offscreen.width / height drawing coordinates what your draw code uses 0…400 × 0…200 1 unit = 1 css px × dpr = 2 re-read on resize ctx.scale(dpr, dpr) a resize clears it Get either step wrong and it shows Buffer left at CSS size offscreen.width = 400, not 400 × dpr one canvas pixel is stretched across two device pixels — soft text and lines coarse pixels ctx.scale(dpr, dpr) skipped the buffer is right, the coordinates are not — the whole drawing lands in the top-left quarter of the canvas drawn at 1×
The buffer is the CSS box multiplied by devicePixelRatio; ctx.scale(dpr, dpr) hands the drawing code its CSS-pixel coordinate system back. Both assignments are wiped whenever width or height is set again.
ResizeObserver and matchMedia live on the main thread

Neither ResizeObserver nor window.matchMedia exists in a worker scope, so the worker cannot observe its own size, the DPR, prefers-color-scheme or prefers-reduced-motion. Every one of those signals has to be observed on the page and posted in. Treat the init message as a snapshot of the environment and add a dedicated env message for later changes — it is far cheaper than discovering at review time that a themed chart never repaints when the user flips to dark mode.

5. Transfer Happens Before the Worker Is Ready — and That Is Fine

postMessage on a freshly constructed Worker is safe even though the worker script has not finished loading: messages are queued against the port and delivered once the global scope installs its onmessage handler. What is not safe is assuming the reverse — the main thread learns nothing about a failed context acquisition unless the worker tells it. Keep the worker.onerror handler from the example, and have the worker post an explicit { type: 'error' } when getContext returns null, which happens in practice when a page has exhausted the per-page WebGL context limit or when the tab is under memory pressure.

Performance Note

The transfer itself is effectively free and effectively constant: the handle is a pointer hand-off, so postMessage with the canvas in the transfer list costs well under 0.1 ms regardless of canvas size — a 4000×4000 surface transfers as fast as a 300×150 one. Nothing is copied, which is why this is a startup-time cost you never think about again.

The gain shows up in frame delivery, not frame cost. Moving an existing 60 fps loop into a worker removes its 4–12 ms of per-frame main-thread work on a mid-range 2023 laptop, and — more importantly — takes the render loop out of the queue behind layout, script and garbage collection. On a page with virtualized lists, CSS animations and frequent DOM mutation, that is the difference between a steady 16.7 ms cadence and periodic 40–80 ms stalls whenever the main thread has a bad frame. The corollary matters just as much: if a single frame already takes 40 ms of drawing, it will still take 40 ms in the worker. OffscreenCanvas makes frames reliable, not cheaper. Verify the split in the Chrome Performance panel — paint work and rAF callbacks should appear in the Worker track with the Main track idle, using the workflow in profiling worker CPU usage with the Chrome Performance tab.

The same 6 ms of drawing, scheduled two ways over five frames On the main thread the 6 millisecond draw call queues behind a 24 millisecond script task and a 9 millisecond garbage collection pause, so one frame is dropped and two are presented late. Moving the same draw call into a worker leaves the main thread's bad frames untouched but the worker's requestAnimationFrame keeps an even cadence, and all five frames are presented on time. The same 6 ms of drawing, scheduled two ways Drawing on the main thread — the render loop shares the queue Drawing in the worker — the render loop has its own thread long task — 24 ms GC 9 ms on time on time dropped late late long task — 24 ms GC 9 ms identical bad frames, no draw work on time on time on time on time on time frame 1 — 16.7 ms frame 2 frame 3 frame 4 frame 5 Main thread Presented Main thread Worker Presented Frames draw work, 6 ms long main-thread task garbage collection dashed rules mark the 16.7 ms frame boundaries
The drawing costs 6 ms either way. What changes is who it queues behind: on the main thread it waits for a 24 ms task and a GC pause and misses its slot three times, while the worker's requestAnimationFrame is scheduled by the compositor and keeps cadence through both.

For the complete implementation pattern built on top of this transfer — streaming data updates, ring buffers and chart-specific draw optimizations — continue to 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 placed in the message payload. Omitting the transfer list makes the browser attempt a structured clone instead, and because OffscreenCanvas is not cloneable the call throws DataCloneError: Failed to execute 'postMessage' on 'Worker'. The correct form names the object twice: worker.postMessage({ canvas: offscreen }, [offscreen]) — once as data, once as the thing whose ownership moves.
Can I call transferControlToOffscreen on a canvas that already has a 2D context?
No. Once canvas.getContext('2d') (or 'webgl', 'webgl2', 'bitmaprenderer') has run on the DOM canvas, the element owns a rendering context and transferControlToOffscreen() throws InvalidStateError. Transfer has to happen before any context is acquired on the main thread, which in practice means before a charting library, a polyfill, or a devtools helper touches the element. If you cannot guarantee that ordering, render into a fresh <canvas> you create yourself and transfer that one.

See also