Rendering Charts Off the Main Thread

A live chart that keeps painting at the display refresh rate while the page around it is busy — because the render loop, the data buffer and the drawing code all live inside the worker.

This is the payoff case for OffscreenCanvas Rendering, the technique set that belongs to High-Performance Computation Patterns. The mechanics of handing the canvas over — the one-way transferControlToOffscreen() call, the transfer list, the resize protocol — are covered in Transferring Canvas Control to a Worker; this page assumes the canvas is already in the worker and solves the part that is specific to charts: a continuously arriving series, a fixed visible window, and a draw call that has to stay bounded no matter how fast the data comes in.

The shape of the solution is three decoupled loops. The socket delivers readings whenever it likes. The main thread does nothing with them except batch and forward. The worker owns a fixed-size ring buffer and redraws on its own animation frames, reading whatever happens to be in the buffer at that instant. No loop waits on another, so a 200 ms main-thread task delays the arrival of new data and never the drawing of what already arrived.

Minimal Reproducible Example

Three files: the page, a main-thread batcher, and the chart worker. The example simulates the stream so it runs standalone; swap simulateStream for a WebSocket message handler and nothing else changes.

Three decoupled loops carry a live series from the socket to the worker’s canvas A data-flow diagram split by a dashed thread boundary. On the main thread, an irregular socket handler writes each reading into a 4096-slot staging Float32Array, and a fixed 16 millisecond timer slices the filled prefix and posts it. The ArrayBuffer crosses the boundary by transfer rather than structured clone, about 480 bytes in under 0.05 milliseconds. On the worker side the batch is appended to an 1800-slot ring buffer holding a 3.6 second window, a separate requestAnimationFrame loop reads whatever the buffer currently holds and strokes one path, and the compositor presents the result. No loop waits on another. Three loops, three clocks — none of them waits on another Main thread Worker thread Socket message handler ingest(value) — one array write, no send irregular stage — Float32Array(4096) filled prefix only; overflow counted hard cap setInterval(flush, 16 ms) slice → postMessage(buf, [buf]) fixed 16 ms OffscreenCanvas compositor presents what the worker drew on screen self.requestAnimationFrame clear · grid · one path · 2–5 ms every vsync ring — Float32Array(1800) ring[head++ % CAPACITY] = value 3.6 s window staged, never sent one by one flushed on the timer’s clock reads whatever is there paints the transferred canvas ArrayBuffer moves ≈480 B · <0.05 ms no structured clone thread boundary A 200 ms task here delays the arrival of new data — never the drawing of what already arrived. loop 1 the source · loop 2 the 16 ms flush · loop 3 the worker’s own animation frames
Three clocks, no shared one. The socket sets the arrival rate, the timer sets the message rate, and the compositor sets the draw rate — so a stalled main thread can delay new data without ever delaying a frame.

HTML

<!-- index.html -->
<canvas id="live-chart" style="width:100%;height:300px;"></canvas>
<script type="module" src="./main.ts"></script>

Main Thread

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

const worker = new Worker(new URL('./chart-worker.ts', import.meta.url), {
  type: 'module',
});
worker.onerror = (e: ErrorEvent) => {
  console.error('chart worker failed:', e.message, e.filename, e.lineno);
};

// Measure INSIDE a frame callback: clientWidth is 0 until layout has run.
requestAnimationFrame(() => {
  const dpr = window.devicePixelRatio;
  canvas.width = Math.round(canvas.clientWidth * dpr);
  canvas.height = Math.round(canvas.clientHeight * dpr);

  // One-way handoff. Nothing on this thread may draw into the canvas again.
  const offscreen = canvas.transferControlToOffscreen();
  worker.postMessage({ type: 'init', canvas: offscreen, dpr }, [offscreen]);

  // ResizeObserver has no worker equivalent — it stays here and reports.
  new ResizeObserver(() => {
    worker.postMessage({
      type: 'resize',
      width: canvas.clientWidth,
      height: canvas.clientHeight,
      dpr: window.devicePixelRatio,
    });
  }).observe(canvas);
});

// --- Ingest: stage readings, flush on a fixed cadence -----------------------

const FLUSH_MS = 16;        // ~one batch per displayed frame
const STAGE_CAPACITY = 4096; // hard ceiling per flush; excess is dropped
const stage = new Float32Array(STAGE_CAPACITY);
let staged = 0;
let dropped = 0;

/** Call this from the socket handler. Cost: one array write. */
function ingest(value: number): void {
  if (staged < STAGE_CAPACITY) stage[staged++] = value;
  else dropped++;  // surfaced below rather than silently swallowed
}

setInterval(() => {
  if (staged === 0) return;

  // Right-size the batch so the transfer carries only live samples.
  const batch = stage.slice(0, staged);
  staged = 0;

  // The ArrayBuffer moves; `batch` is detached the instant this returns.
  worker.postMessage({ type: 'data', buffer: batch.buffer, dropped }, [batch.buffer]);
  dropped = 0;
}, FLUSH_MS);

// --- Simulated source: 500 readings/s, irregular arrival --------------------
function simulateStream(): void {
  setInterval(() => {
    for (let i = 0; i < 5; i++) {
      ingest(Math.sin(Date.now() / 500 + i * 0.1) * 40 + 50 + Math.random() * 6);
    }
  }, 10);
}
simulateStream();

Worker

// chart-worker.ts
const CAPACITY = 1800;                        // visible window: 3.6 s at 500 Hz
const ring = new Float32Array(CAPACITY);
let head = 0;                                 // total writes, never wrapped
let count = 0;                                // valid samples, capped at CAPACITY
let droppedTotal = 0;

let ctx: OffscreenCanvasRenderingContext2D | null = null;
let dpr = 1;

interface InitMsg   { type: 'init';   canvas: OffscreenCanvas; dpr: number }
interface ResizeMsg { type: 'resize'; width: number; height: number; dpr: number }
interface DataMsg   { type: 'data';   buffer: ArrayBuffer; dropped: number }
type Msg = InitMsg | ResizeMsg | DataMsg;

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

  if (msg.type === 'init') {
    dpr = msg.dpr;
    ctx = msg.canvas.getContext('2d');
    if (!ctx) {
      self.postMessage({ type: 'error', message: 'no 2d context' });
      return;
    }
    ctx.scale(dpr, dpr);                      // draw in CSS-pixel coordinates
    self.requestAnimationFrame(renderFrame);
  }

  if (msg.type === 'resize' && ctx) {
    dpr = msg.dpr;
    ctx.canvas.width = Math.round(msg.width * dpr);
    ctx.canvas.height = Math.round(msg.height * dpr);
    ctx.scale(dpr, dpr);                      // sizing reset the transform
  }

  if (msg.type === 'data') {
    droppedTotal += msg.dropped;
    const incoming = new Float32Array(msg.buffer); // view over transferred memory
    for (let i = 0; i < incoming.length; i++) {
      ring[head++ % CAPACITY] = incoming[i];
      if (count < CAPACITY) count++;
    }
  }
};

// ---- Drawing ---------------------------------------------------------------

const PAD = { top: 20, right: 52, bottom: 26, left: 44 };
const Y_MIN = 0;
const Y_MAX = 100;

function renderFrame(): void {
  self.requestAnimationFrame(renderFrame);    // schedule before drawing
  if (!ctx || count === 0) return;

  const t0 = performance.now();
  const W = ctx.canvas.width / dpr;
  const H = ctx.canvas.height / dpr;
  const plotW = W - PAD.left - PAD.right;
  const plotH = H - PAD.top - PAD.bottom;
  const toY = (v: number) =>
    PAD.top + plotH - ((v - Y_MIN) / (Y_MAX - Y_MIN)) * plotH;

  ctx.clearRect(0, 0, W, H);

  // Grid + value axis
  ctx.strokeStyle = 'rgba(120,120,120,0.28)';
  ctx.fillStyle = '#5c5c5c';
  ctx.font = '11px system-ui, sans-serif';
  ctx.textAlign = 'right';
  ctx.textBaseline = 'middle';
  ctx.lineWidth = 1;
  for (let i = 0; i <= 4; i++) {
    const v = Y_MAX - ((Y_MAX - Y_MIN) / 4) * i;
    const y = Math.round(toY(v)) + 0.5;       // half-pixel: crisp 1px rules
    ctx.beginPath();
    ctx.moveTo(PAD.left, y);
    ctx.lineTo(PAD.left + plotW, y);
    ctx.stroke();
    ctx.fillText(String(v), PAD.left - 8, y);
  }

  // Series: oldest valid sample first
  const start = count < CAPACITY ? 0 : head % CAPACITY;
  const step = plotW / (CAPACITY - 1);

  ctx.beginPath();                            // MUST reset the path each frame
  ctx.strokeStyle = '#b3620d';
  ctx.lineWidth = 1.5;
  ctx.lineJoin = 'round';
  for (let i = 0; i < count; i++) {
    const v = ring[(start + i) % CAPACITY];
    const x = PAD.left + i * step;
    const y = toY(v);
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();

  // Latest reading, pinned to the right gutter
  const latest = ring[(head - 1 + CAPACITY) % CAPACITY];
  ctx.fillStyle = '#b3620d';
  ctx.font = 'bold 12px system-ui, sans-serif';
  ctx.textAlign = 'left';
  ctx.fillText(latest.toFixed(1), PAD.left + (count - 1) * step + 6, toY(latest));

  // Draw-time budget readout (development only)
  const drawMs = performance.now() - t0;
  ctx.fillStyle = drawMs > 8 ? '#a03705' : '#5c5c5c';
  ctx.font = '10px ui-monospace, monospace';
  ctx.textAlign = 'right';
  ctx.fillText(
    `${drawMs.toFixed(1)} ms · ${count} pts${droppedTotal ? ` · ${droppedTotal} dropped` : ''}`,
    W - 4, H - 8,
  );
}

Line-by-Line Walkthrough

requestAnimationFrame around the measure-and-transfer block. clientWidth is 0 until the element has been laid out, and a canvas transferred at 0 × 0 stays 0 × 0 until the first resize message arrives — the classic “worker chart renders nothing” bug. Deferring one frame guarantees layout has run. The ResizeObserver is registered afterwards because its first callback fires immediately with the current box and would otherwise race the init message.

Staging array plus a fixed flush interval. The socket handler’s only job is stage[staged++] = value. Everything expensive — allocation, slicing, postMessage — happens once per FLUSH_MS regardless of arrival rate. This is what keeps a 5 kHz sensor from turning into 5000 messages per second, and it decouples ingest cost from stream rate entirely. The interval is a timer rather than a main-thread requestAnimationFrame deliberately: when the page stalls, frame callbacks stall with it, and the flush would starve exactly when the worker most needs to keep drawing.

stage.slice(0, staged) then transferring batch.buffer. slice copies the live prefix into a right-sized array so the transfer carries 40 samples rather than the full 4096-slot staging capacity. The ArrayBuffer is then listed in the transfer list, so ownership moves and no structured clone runs — the general mechanism is Transferable Objects & Zero-Copy. Transferring the staging array directly would be faster still but would detach it, forcing a fresh allocation each flush; copying a few hundred bytes is cheaper than reallocating 16 KB.

head counts total writes and is never wrapped. Only the index expression wraps, via head % CAPACITY. Keeping the monotonic counter makes “is the buffer full yet” a comparison rather than a flag, gives you a free total-samples-received metric, and makes the read start position fall out directly: head % CAPACITY is the oldest slot once count === CAPACITY. At 500 Hz a double-precision head stays exact for roughly 570,000 years, so overflow is not a concern.

A fixed-capacity ring buffer with the write head mid-wrap A 24-slot ring stands in for the 1800-slot Float32Array. Six consecutive slots shaded as the batch just appended run past the end of the array and continue at slot zero, showing the wrap. The slot immediately after them is the head: it is simultaneously the next write position and the oldest sample still held, so the next append overwrites it. An arrow outside the ring shows the read order running clockwise from head modulo capacity. Four cards explain that head counts total writes and never wraps, that only the index expression wraps, that count saturates at capacity, and that appending overwrites in place with no allocation. The ring after 7 213 921 writes, with the head mid-wrap slot 1799 slot 0 ring Float32Array(1800) 3.6 s at 500 Hz head — next write and the oldest sample read order from head % 1800, then +1, clockwise head — 7 213 921 total writes; the counter itself never wraps exact in a double for ~570 000 years at 500 Hz head % 1800 → slot 1521 the only place the wrap happens is the index expression — no flag, no branch, no reset count — 1800, saturated below capacity the read starts at slot 0; at capacity it starts at head % CAPACITY the batch overwrites in place no allocation, no shifting, no length check — the oldest six samples cease to exist the batch just appended — 6 samples, wrapped past slot 1799 older samples still inside the 3.6 s window head — the next write, and the oldest live sample
The head slot does double duty: it is both the next write position and the oldest sample still in the window. That is why head % CAPACITY is the read start once count has saturated.

new Float32Array(msg.buffer) in the data handler. The buffer arrived by transfer, so this wraps existing memory rather than copying it — construction is O(1) irrespective of length. The handler runs to completion on the worker’s event loop between animation frames, so it can never interleave with renderFrame. That single-threaded guarantee is what lets this ring buffer skip locking entirely; a version fed concurrently by a second worker through a SharedArrayBuffer does need synchronisation, which is the subject of Building a Lock-Free Ring Buffer with Atomics.

self.requestAnimationFrame(renderFrame) on the first line of the function. Scheduling before drawing keeps the cadence pinned to the display’s refresh rate even when a frame runs long; scheduling after the draw would add the draw time to the interval and let a slow frame permanently lower the effective rate.

ctx.scale(dpr, dpr) after every dimension assignment. Writing to canvas.width or canvas.height resets the context to its default state — transform, styles, path. Reapplying the scale immediately means every drawing coordinate below is in CSS pixels, which is why W and H are computed as ctx.canvas.width / dpr. Skip the scale at init and the chart draws into the top-left quadrant on a 2× display; skip it after resize and it does the same the first time the window moves between monitors.

Math.round(y) + 0.5 for grid lines. A 1px stroke centred on an integer coordinate straddles two device pixel rows and renders as a 2px blur. Snapping to a half-pixel gives a crisp rule. It matters more here than on the main thread because a static grid redrawn 60 times a second makes any softness obvious.

ctx.beginPath() before the series loop. Without it, every frame appends to the previous frame’s path. The visual result looks correct for a second or two and then stroke() time climbs linearly until the tab is unusable — the single most common cause of a worker chart that “gets slower the longer it runs”.

Keeping the Draw Call Bounded

The example above is O(samples per frame). That is fine while the window holds fewer points than the plot is wide, and wrong as soon as it does not: a 900-pixel plot showing 60 seconds at 1 kHz asks the rasteriser to stroke 60,000 segments into 900 columns, most of them sub-pixel. Cost scales with the stream rate, and the extra work buys nothing visible.

Reduce each pixel column to the two values that actually change its appearance — the minimum and the maximum inside that column — and stroke one vertical segment per column. Path length becomes a function of canvas width, so the draw call costs the same at 1 kHz as at 100 kHz.

// chart-worker.ts — replaces the series loop when count > available columns
function strokeDecimated(
  ctx: OffscreenCanvasRenderingContext2D,
  start: number, n: number,
  x0: number, plotW: number,
  toY: (v: number) => number,
): void {
  const columns = Math.max(1, Math.floor(plotW));
  const perColumn = n / columns;

  ctx.beginPath();
  for (let c = 0; c < columns; c++) {
    const from = Math.floor(c * perColumn);
    const to = Math.min(n, Math.floor((c + 1) * perColumn));
    if (to <= from) continue;

    let lo = Infinity;
    let hi = -Infinity;
    for (let i = from; i < to; i++) {
      const v = ring[(start + i) % CAPACITY];
      if (v < lo) lo = v;
      if (v > hi) hi = v;
    }

    const x = Math.round(x0 + c) + 0.5;
    ctx.moveTo(x, toY(hi));
    ctx.lineTo(x, toY(lo === hi ? lo - 0.001 : lo)); // keep flat columns visible
  }
  ctx.stroke();
}

The scan still touches every sample, but that loop is a tight typed-array read at roughly 1–2 ns per element — 60,000 samples cost about 0.1 ms — while the path it produces is 900 segments instead of 60,000. Compared with naive stride sampling (take every k-th point), min/max decimation is the only cheap method that cannot hide a spike: a one-sample transient is by definition the maximum of its column and is always drawn.

Full path, stride sampling and min/max column decimation compared on one series Three plots of the identical 960-sample series, each 236 pixels wide, with a single-sample spike near the middle circled in each. The left plot strokes every sample and needs 959 segments; the spike is drawn. The middle plot keeps every twentieth sample and needs 59 segments; the spike falls between kept samples and disappears, and the noise envelope shrinks to the sampled points. The right plot reduces each of 60 pixel columns to its minimum and maximum and strokes 60 vertical segments; the envelope matches the full path and the spike survives, because a transient is by definition the maximum of its column. The same 960-sample window drawn three ways into a 236-pixel plot every sample every 16th sample min/max per column 959 segments spike drawn cost grows with the stream rate 59 segments spike gone the envelope collapses to the kept samples 60 segments spike preserved cost fixed by the canvas width At the working ratio — 60 000 samples into a 900-pixel plot — stride keeps one sample in 66. Min/max keeps every column’s extremes, so a one-sample transient is drawn every time.
Two of these three cost the same to draw. Only stride sampling can lose a spike — min/max decimation bounds the path by canvas width without ever hiding a transient.

Gotchas and Edge Cases

1. The Transferred Buffer Is Detached on the Sender

After postMessage({ buffer }, [buffer]), reading batch[0] on the main thread returns undefined and batch.length is 0. Any code that logs, hashes, or re-renders a preview from the batch after the send silently starts working on an empty array — no exception is thrown. Do all main-thread work with the values before the send, or keep a small summary (last value, min, max) computed during staging.

2. Custom Fonts Do Not Exist in a Worker

There is no document.fonts in a worker scope, so ctx.font = '12px Inter' falls back to the platform sans-serif with no warning — and it does so silently, meaning axis labels shift by a few pixels between the design mock and production. Use system stacks (system-ui, ui-monospace) for anything drawn inside the worker, or load the face with FontFace and self.fonts.add() where supported, then measure with ctx.measureText after the load promise resolves rather than assuming metrics.

3. A Stalled Worker Grows the Message Queue Without Bound

postMessage never applies backpressure. If the worker’s event loop is blocked — a long synchronous computation, a debugger pause, a hidden tab throttling requestAnimationFrame to once per second — batches keep queuing and the queue grows until memory pressure ends the tab. The staging cap and dropped counter in the example bound the ingest side; bound the transport side too by having the worker acknowledge each batch and pausing the flush when more than a few acknowledgements are outstanding. A chart that shows the last 3.6 seconds has no use for a 30-second backlog anyway: dropping is the correct behaviour, and counting the drops is what makes it honest.

4. Background Tabs Stop the Worker’s Frames, Not Its Data

self.requestAnimationFrame inside the worker is driven by the compositor and stops when the tab is hidden, exactly like its main-thread counterpart. Timers keep firing at a throttled rate, so data keeps flowing into the ring buffer and simply overwrites itself — which is the desired outcome, since the buffer holds a fixed window. What is not desirable is a setInterval-driven fallback renderer running in a hidden tab. Gate the flush on document.visibilityState if the source is pollable, and never substitute a timer for requestAnimationFrame in the worker’s draw loop.

5. Zero-Width Canvases Survive Longer Than You Expect

If the canvas is inside a collapsed accordion or a display: none container at init, the deferred measurement still reads 0. Guard the transfer on canvas.clientWidth > 0 and retry from the ResizeObserver callback, because transferControlToOffscreen() can only ever be called once per element — get it wrong and recovery means replacing the DOM node.

The canvas sizing sequence, paired with the failure each shortcut produces Five numbered steps down a spine, each paired with the defect that appears if it is skipped. Step one: the element is in the DOM but clientWidth is zero until layout runs, so measuring now yields a zero by zero canvas. Step two: measure inside a requestAnimationFrame callback, otherwise a canvas transferred at zero by zero stays that size until a resize message arrives. Step three: set width and height to the CSS size times devicePixelRatio, otherwise a one times buffer is stretched over a two times box. Step four: call transferControlToOffscreen once and post the handle in the transfer list; a second call throws and recovery means replacing the DOM node. Step five: after any resize, reassign the dimensions and reapply the scale, otherwise drawing lands in the top-left quadrant. Canvas sizing: the order that works, and what each shortcut costs The sequence What skipping it costs 1 The element is in the DOM clientWidth is 0 until layout has run Measuring now gives 0 × 0 the classic “renders nothing” bug starts here 2 Measure inside a requestAnimationFrame callback layout has run, so clientWidth and clientHeight are real Transfer at 0 × 0 and it stays 0 × 0 until some resize message happens to arrive 3 canvas.width / height = CSS size × devicePixelRatio the backing store, counted in device pixels Skip the DPR multiply and it is soft a 1× buffer stretched over a 2× box 4 transferControlToOffscreen(), then postMessage once per element, ever — the handle rides in the transfer list Call it twice and it throws recovery means replacing the DOM node 5 On resize: set width and height, then ctx.scale(dpr, dpr) assigning either dimension resets the whole context state Forget the rescale and it draws small everything lands in the top-left quadrant
Every step has exactly one failure mode, and every failure mode is silent. Only step four throws — the other four just render something that looks slightly wrong forever.

Performance Note

Measured on a 2023 MacBook Pro (M2, Chrome 124, 60 Hz display), charting a 1800-sample window fed at 500 readings/second:

Scenario Main-thread frame time Chart draw time Observed
Chart on the main thread, page idle 9 ms 8 ms (of the 9) 60 fps
Chart on the main thread, framework re-rendering 18–22 ms 8 ms 40–50 fps, visible jank
Chart in an OffscreenCanvas worker, page idle < 0.5 ms (flush only) 3 ms 60 fps
Chart in an OffscreenCanvas worker, framework re-rendering < 0.5 ms 3 ms 60 fps, stable

Two numbers matter in that table. The chart draw does not get cheaper by moving — 8 ms of Canvas 2D work becomes 3 ms mostly because the decimated path is shorter, not because the thread changed. What changes is who waits: the worker’s frame callback is scheduled by the compositor and never queues behind layout, style recalculation or a framework’s reconciliation pass. On a mid-range Android device (Snapdragon 778G) the same comparison is starker — the worker chart holds 60 fps through a 50 ms main-thread layout, while the main-thread version drops to 25–30 fps because its frame callbacks are simply not reached in time.

The rule of thumb: move the chart when its draw call exceeds ~4 ms and the page has real scripting load. Below about 2 ms of draw time on a quiet page, the coordination cost — an extra message hop, a duplicated resize protocol, a render path you cannot inspect with the element inspector — outweighs a saving you could not measure in the first place. Verify the move actually happened rather than assuming it: record a few seconds as described in Profiling Worker CPU Usage with the Chrome Performance Tab, and confirm the animation frames and paint work sit on the worker track while the main track shows nothing but the periodic flush.

Main-thread cost per frame for four chart placements against the 16.67 ms budget A horizontal bar chart with four scenarios, each showing main-thread time per frame above the chart draw time. With the chart on the main thread and the page idle, the frame costs 9 milliseconds, 8 of which is the draw, and it fits inside the dashed 16.67 millisecond budget line. With a framework re-rendering, the same scenario reaches 18 to 22 milliseconds and crosses the budget line. With the chart in an OffscreenCanvas worker, main-thread cost collapses to under half a millisecond in both the idle and the busy case, while the 3 millisecond draw moves to the worker track and never competes for the budget. Per-frame main-thread cost — 1800-sample window at 500 readings/second main-thread time per frame chart draw, main thread chart draw, worker track 0 5 10 15 20 25 ms 16.67 ms frame budget chart on the main thread page idle 9 ms of which 8 ms is the draw chart on the main thread framework re-rendering 18–22 ms the same 8 ms draw, now queued chart in an OffscreenCanvas worker page idle < 0.5 ms 3 ms, on the worker track chart in an OffscreenCanvas worker framework re-rendering < 0.5 ms 3 ms, still on the worker track milliseconds of main-thread work per frame
Moving the chart does not shrink the draw; it moves the draw off the bar that has to fit inside 16.67 ms. The saving therefore scales with how busy the page is, not with how complex the chart is.

Frequently Asked Questions

How do I feed live data to a chart running in a worker without blocking the main thread?
Post deltas, never the full series, and post them in batches. Accumulate incoming readings on the main thread into a Float32Array, flush once per ~16 ms on a timer, and hand the underlying ArrayBuffer to postMessage in the transfer list so ownership moves instead of the payload being copied. A 480-byte batch of 120 readings transfers in well under 0.05 ms because the cost is a pointer handoff plus one message-queue enqueue, not a structured clone proportional to the data. The worker wraps the buffer in a view and appends to its ring buffer, so the main thread’s total per-batch cost is the allocation and the send.
What frame rate can a chart realistically hold inside an OffscreenCanvas worker?
A Canvas 2D line chart drawing 2000 visible points holds a stable 60 fps in a worker on a 2023-class laptop, with 2–5 ms of draw time per frame. The same chart drawn on the main thread of a page that is also running scroll handlers and framework re-renders falls to 40–50 fps, because its requestAnimationFrame callback queues behind that work inside the same 16.67 ms budget. The worker version does not draw faster — the draw call costs the same — it just stops competing for the slot, which is why the win grows with how busy the page is rather than with how complex the chart is.

See also