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.
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.
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.
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.
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.