Workers
The core has no DOM, so a whole chart can run in a Web Worker and draw on an OffscreenCanvas. The main thread keeps the canvas element and the input; a long task on the main thread no longer stalls the chart. Worker rendering is the running version of this page.
What goes to the worker
Everything Plot needs, wired from @finchart/core alone (@finchart/dom needs a document and stays on the main thread):
- Layers — a
ChartLayersover theOffscreenCanvas, withoverlay: null. There is no overlay, so no DOM labels, tooltip, legend or divider handles; axis labels come fromcreateCanvasAxisLabels. - Style — a
StyleReaderover a plain object instead of CSS variables. The worker can't read the page's stylesheets, so the theme is sent with the first message. - Scheduler —
frameScheduler()uses the worker'srequestAnimationFramewhen it has one, and draws immediately when it doesn't. - Data and timers — the series, the feed and anything that updates them.
Loading the worker
const worker = new Worker(new URL("./chart.worker.ts", import.meta.url), { type: "module" });This is the form Vite and webpack 5 document: the bundler finds the new URL(..., import.meta.url) and emits the worker as its own chunk. Other bundlers have their own rules — check theirs before relying on this line.
Bridging input
No package bridges input to a worker. Plot implements InteractionTarget, so the worker calls those methods when a message arrives, and the main thread sends the messages from pointer events on the canvas element:
| Main thread | Worker |
|---|---|
drag by dx pixels | plot.panByPixels(dx) |
wheel at x | plot.zoomAtPixel(factor, x) |
pointer at x, y (or off the chart) | plot.crosshair({ x, y }) |
Positions are CSS pixels relative to the canvas, the same space the chart lays out in.
Falling back
Checking that Worker and transferControlToOffscreen exist is not enough. The worker module can fail to load, the transfer or the first message can throw, and getContext("2d") on the OffscreenCanvas can return null inside the worker. After a transfer the canvas element can't be drawn on by the main thread any more. The example handles all of it in one place:
- Without
WorkerortransferControlToOffscreen, build the chart on the main thread right away. - Otherwise make the worker, listen for
error,messageerrorand its messages, transfer the canvas and send the first message, all inside onetry. - The worker answers
{ type: "ready" }once the chart stands, or{ type: "failed", reason }when it can't — a missing 2D context is reported this way rather than thrown. - On
failed,error, a throw in step 2, or no ready before a timeout, terminate the worker, remove the transferred canvas, and build the chart on the main thread on a new canvas. - If the page goes away first, terminate the worker and stop listening, so a late message does nothing.
/**
* Starting a chart that renders in a worker, with a way back.
*
* Checking that `Worker` and `transferControlToOffscreen` exist is not
* enough: the worker module can fail to load (a CSP, a bundler that didn't
* emit it), the transfer or the first message can throw, and the worker's
* own `getContext("2d")` can come back null. Every one of those ends here,
* in one place: the worker is terminated and `onFallback` draws the chart
* on the main thread. A canvas whose control was transferred can't be drawn
* on again, so the fallback needs a new one.
*/
/** Worker → main: the handshake this start waits for. */
export type WorkerReport = { type: "ready" } | { type: "failed"; reason: string };
export interface WorkerRenderStart {
/** The canvas to hand to the worker. After a transfer it belongs to the worker for good. */
canvas: HTMLCanvasElement;
/** Makes the worker — `new Worker(new URL("./x.worker.ts", import.meta.url), { type: "module" })`. */
createWorker(): Worker;
/** The first message, carrying the transferred canvas; the canvas goes in its transfer list. */
initMessage(canvas: OffscreenCanvas): unknown;
/** The worker reported ready — the chart runs there from now on. */
onReady(worker: Worker): void;
/**
* Something failed, before or after ready: the worker is already
* terminated. Draw the chart on the main thread, on a new canvas. Called
* at most once.
*/
onFallback(reason: string): void;
/** How long to wait for the worker's ready (ms). Default 3000. */
timeout?: number;
}
const DEFAULT_TIMEOUT = 3000;
/** The report a message carries, or null for a message that isn't one. A failure without a reason is still a failure. */
function reportOf(data: unknown): WorkerReport | null {
if (typeof data !== "object" || data === null) return null;
const type = Reflect.get(data, "type");
if (type === "ready") return { type };
if (type !== "failed") return null;
const reason = Reflect.get(data, "reason");
return { type, reason: typeof reason === "string" ? reason : "the worker reported a failure" };
}
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/**
* Starts the worker and returns a stop function: before ready it terminates
* the worker and nothing that arrives later is heard; after a fallback it
* does nothing (the fallback's chart is the caller's to dispose).
*/
export function startWorkerRender(start: WorkerRenderStart): () => void {
const supported =
typeof Worker === "function" && "transferControlToOffscreen" in HTMLCanvasElement.prototype;
if (!supported) {
start.onFallback("this browser can't render a canvas in a worker");
return () => undefined;
}
let worker: Worker | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
/**
* Where the start is. A stand-in worker can answer from inside
* `postMessage`, before the wait begins, so a report is judged by this
* rather than by whether the timer exists yet.
*/
let phase: "starting" | "waiting" | "ready" | "over" = "starting";
/** Takes the worker down and stops listening — shared by a failure and a stop. */
const release = (): void => {
phase = "over";
if (timer !== null) clearTimeout(timer);
timer = null;
if (worker) {
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
worker.removeEventListener("messageerror", onError);
worker.terminate();
}
worker = null;
};
// At most once: `release` removes the listeners and the timer first, and
// no timer is started once the start is over.
const fail = (reason: string): void => {
release();
start.onFallback(reason);
};
function onMessage(event: MessageEvent): void {
const report = reportOf(event.data);
if (report === null) return;
if (report.type === "failed") {
fail(report.reason);
return;
}
if (phase === "ready" || phase === "over" || !worker) return;
phase = "ready";
if (timer !== null) clearTimeout(timer);
timer = null;
start.onReady(worker);
}
function onError(): void {
fail("the worker failed to load or crashed");
}
try {
worker = start.createWorker();
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
worker.addEventListener("messageerror", onError);
const offscreen = start.canvas.transferControlToOffscreen();
worker.postMessage(start.initMessage(offscreen), [offscreen]);
} catch (error) {
fail(message(error));
return () => undefined;
}
// Waited for only if no report came back while posting.
if (phase === "starting") {
phase = "waiting";
const timeout = start.timeout ?? DEFAULT_TIMEOUT;
timer = setTimeout(() => fail(`the worker did not report ready within ${timeout}ms`), timeout);
}
return release;
}What live updates cost
Moving the chart to a worker moves drawing off the main thread. It does not make ticks free.
- A tick is a message. Each
postMessagefrom the socket's thread to the worker is a structured clone, a queued task and a dispatch, before the chart does anything with it. conflatedin the worker saves deliveries, not messages. It holds the latest bar and callsupdateLastonce per frame, but every tick has already crossed as a message by then. To send fewer messages, batch on the sending side.- Without
requestAnimationFramein the worker,conflateddelivers each tick at once. Its default schedule isframeScheduler(), which has no frame to wait for there.
The example makes its bars inside the worker on a timer, to show the chart keeps running while the main thread is stuck. It is not a model for a tick protocol.