Worker rendering
The whole chart (Plot, data, rAF) lives in a worker and the main thread only forwards input as messages — drag pan, wheel zoom, and crosshair are synthesized InteractionTarget calls. Stall the main thread with 'Jam main 3s' and the bars keep arriving. The theme isn't CSS here, it's a JS object in the worker (an injected StyleReader). Where a worker can't render — no OffscreenCanvas, a worker that fails to load or reports no 2D context, no ready within 3 seconds — the same chart is drawn on the main thread and the note says why.
The worker builds the chart from the core alone: layers over an OffscreenCanvas with no overlay, a StyleReader over a JS object, canvas tick labels, and frameScheduler(). The main thread keeps only the canvas element and turns pointer events into panByPixels, zoomAtPixel and crosshair messages.
Starting it is startWorkerRender, and every way it can fail ends the same way: the worker is terminated, the transferred canvas is removed, and the chart is built on the main thread on a new canvas. The failures it catches are no Worker or no transferControlToOffscreen, a worker that can't be made or fails to load, a transfer or first message that throws, a worker that reports failed (it couldn't get a 2D context), an error after ready, and no ready within three seconds. Workers explains the pieces.
The bars are made inside the worker on a timer, to show the chart keeps running while the main thread is stuck. That is not a tick protocol — a real feed's messages and what they cost are in the guide.
Source
apps/examples/src/cases/worker-render.ts — the main-thread half.
/**
* Worker rendering — the main-thread half.
*
* This side does three things only: it makes a canvas and hands it to the
* worker with `transferControlToOffscreen`, it translates pointer input into
* messages, and with the "jam the main thread" button it deliberately stalls
* the main thread for 3 seconds — the worker's rAF is none of its business, so
* bars keep arriving, and that is what this case proves.
*
* The chart itself (Plot, data, timers) lives in worker-render.worker.ts —
* unless the worker can't have it. `startWorkerRender` decides: no worker or
* no canvas transfer in this browser, a worker module that fails to load, a
* transfer that throws, a worker that reports it couldn't get a 2D context,
* or no ready in time — then the same chart is drawn right here, on a new
* canvas, and the page says why.
*/
import type { OHLC } from "@finchart/core";
import { candleSeries, crosshair, priceFormat, timeTicks } from "@finchart/core";
import { PlotBuilder, browserDeps } from "@finchart/dom";
import { fixtureCandles } from "./fixture";
import { startWorkerRender } from "./worker-fallback";
import type { MainToWorker } from "./worker-render.worker";
import { chartHost } from "./stage";
export const title = "Worker rendering";
export const description =
"The whole chart (Plot, data, rAF) lives in a worker and the main thread only forwards input as messages — drag pan, wheel zoom, and crosshair are synthesized InteractionTarget calls. Stall the main thread with 'Jam main 3s' and the bars keep arriving. The theme isn't CSS here, it's a JS object in the worker (an injected StyleReader). Where a worker can't render — no OffscreenCanvas, a worker that fails to load or reports no 2D context, no ready within 3 seconds — the same chart is drawn on the main thread and the note says why.";
const HEIGHT = 480;
/** The worker's chart, drawn here instead — a transferred canvas can't be drawn on again, so it gets a fresh host. */
function mainThreadChart(host: HTMLElement, width: number): () => void {
const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
.setSize(width, HEIGHT)
.setAxis({
x: { ticks: timeTicks({ timeZone: "UTC", locale: "en-US" }) },
y: { position: "right", format: priceFormat({ compact: true, locale: "en-US" }) },
})
.build(host);
plot.applyOptions({ shiftVisibleRangeOnNewBar: true, rightOffset: 4 });
plot.use(crosshair({ magnet: true }));
const all = fixtureCandles(900);
let revealed = 300;
const price = plot.mainPane.addSeries({ series: candleSeries(), data: all.slice(0, revealed), name: "Price" });
const timer = window.setInterval(() => {
const next = all[revealed];
if (!next) return;
price.append([next]);
revealed += 1;
}, 400);
return () => {
window.clearInterval(timer);
plot.destroy();
};
}
export function mount(container: HTMLElement): () => void {
const toolbar = document.createElement("div");
toolbar.style.cssText =
"margin-bottom: 8px; display: flex; gap: 8px; align-items: center";
container.append(toolbar);
const host = chartHost(container, HEIGHT);
const width = container.clientWidth || 900;
const canvas = document.createElement("canvas");
canvas.style.cssText = `display: block; width: ${width}px; height: ${HEIGHT}px; touch-action: none`;
host.appendChild(canvas);
const jam = document.createElement("button");
jam.textContent = "Jam main 3s";
const note = document.createElement("span");
toolbar.append(jam, note);
// Pointer → message. The minimal version of the translation
// pointerInteractions (@finchart/dom) does on an element — here the
// destination is a worker instead of an element, and that's the only change.
let worker: Worker | null = null;
const send = (message: MainToWorker) => worker?.postMessage(message);
const local = (event: { clientX: number; clientY: number }) => {
const rect = canvas.getBoundingClientRect();
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
};
let lastPanX: number | null = null;
const onPointerDown = (event: PointerEvent) => {
canvas.setPointerCapture(event.pointerId);
lastPanX = event.clientX;
};
const onPointerMove = (event: PointerEvent) => {
if (lastPanX !== null) {
send({ type: "pan", dx: event.clientX - lastPanX });
lastPanX = event.clientX;
}
const point = local(event);
send({ type: "crosshair", x: point.x, y: point.y });
};
const endPan = () => {
lastPanX = null;
};
const onPointerLeave = () => {
send({ type: "crosshair", x: -1, y: -1 }); // outside the area — the crosshair hides
};
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
send({ type: "zoom", factor, x: local(event).x });
};
const bridge = (on: boolean) => {
const toggle = on ? canvas.addEventListener.bind(canvas) : canvas.removeEventListener.bind(canvas);
toggle("pointerdown", onPointerDown);
toggle("pointermove", onPointerMove);
toggle("pointerup", endPan);
toggle("pointercancel", endPan);
toggle("pointerleave", onPointerLeave);
toggle("wheel", onWheel, { passive: false });
};
let disposeFallback: (() => void) | null = null;
const stop = startWorkerRender({
canvas,
createWorker: () =>
new Worker(new URL("./worker-render.worker.ts", import.meta.url), { type: "module" }),
initMessage: (offscreen): MainToWorker => ({
type: "init",
canvas: offscreen,
width,
height: HEIGHT,
dpr: window.devicePixelRatio || 1,
// The gallery marks dark on <body>, the docs site on <html>.
dark: document.body.classList.contains("dark") || document.documentElement.classList.contains("dark"),
}),
onReady: (ready) => {
worker = ready;
bridge(true);
},
onFallback: (reason) => {
worker = null;
bridge(false);
// The transferred canvas belongs to a dead worker — take it out and
// draw on a new one.
canvas.remove();
disposeFallback = mainThreadChart(host, width);
note.textContent = `Drawing on the main thread: ${reason}.`;
},
});
jam.addEventListener("click", () => {
note.textContent = worker
? "Main thread stalled… (the chart keeps running)"
: "Main thread stalled… (the fallback chart stalls with it)";
// Block one rendered frame later, so the text shows up first.
requestAnimationFrame(() => {
const until = performance.now() + 3000;
while (performance.now() < until) {
// busy wait — reproducing a main-thread jam
}
note.textContent = worker
? "Main stalled for 3 seconds and the bars kept arriving"
: "Main stalled for 3 seconds — on the main thread, the chart stalled too";
});
});
return () => {
stop(); // before ready: terminate and stop listening; after ready: timers, rAF and the Plot go with the worker
bridge(false);
disposeFallback?.();
toolbar.remove();
host.remove();
};
}apps/examples/src/cases/worker-render.worker.ts — the worker.
/**
* The worker half — the whole chart lives here. Plot, data, the tick timer,
* the frame scheduler all belong to the worker, and the main thread does
* nothing but forward input as messages.
*
* The wiring itself is the headless proof:
* - Layers: ChartLayers wrapping an OffscreenCanvas (overlay is null)
* - Style: a StyleReader reading a JS theme object — a theme where there is no CSS
* - Scheduler: frameScheduler() — it probes for and grabs the worker's rAF
* - Labels: the canvas tick-label path
* - Input: synthesized InteractionTarget calls (panByPixels, zoomAtPixel, crosshair)
*/
import type { ChartLayers } from "@finchart/core";
import {
candleSeries,
createCanvasAxisLabels,
createCanvasRenderer,
createCanvasTextMeasurer,
createPlotDeps,
crosshair,
DEFAULT_PADDING,
frameScheduler,
Plot,
priceFormat,
timeTicks,
} from "@finchart/core";
import { fixtureCandles } from "./fixture";
import type { WorkerReport } from "./worker-fallback";
/** Main → worker. The case (worker-render.ts) imports it type-only. */
export type MainToWorker =
| {
type: "init";
canvas: OffscreenCanvas;
width: number;
height: number;
dpr: number;
dark: boolean;
}
| { type: "pan"; dx: number }
| { type: "zoom"; factor: number; x: number }
| { type: "crosshair"; x: number; y: number };
/**
* The same values as the gallery's dark palette (theme.css) — as a JS object,
* not CSS. Light is left empty: every leaf falling through to the code default
* is part of the demonstration too (the three tiers of override > variable >
* default).
*/
const DARK: Record<string, string> = {
"--chart-grid": "#1e293b",
"--chart-label": "#94a3b8",
"--chart-crosshair": "#475569",
"--chart-crosshair-badge": "#0f172a",
"--chart-crosshair-badge-back": "#94a3b8",
"--chart-candle-up": "#22c55e",
"--chart-candle-down": "#f87171",
};
/**
* ChartLayers over an OffscreenCanvas — it follows dom-layers' backing-store
* discipline exactly: the grid at device resolution, coordinates in CSS
* pixels, and nothing at all when nothing changed (it is called every frame).
*/
function offscreenLayers(
canvas: OffscreenCanvas,
width: number,
height: number,
dpr: number,
): ChartLayers {
const context = canvas.getContext("2d");
// Reported to the main thread by `onmessage` below, which then draws there instead.
if (!context) throw new Error("no 2D context on the OffscreenCanvas");
let logical = { width, height };
let applied = { width: 0, height: 0 };
const sizeBackingStore = (): void => {
if (logical.width === applied.width && logical.height === applied.height) {
return;
}
canvas.width = Math.round(logical.width * dpr);
canvas.height = Math.round(logical.height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
applied = { ...logical };
};
sizeBackingStore();
return {
data: {
get width() {
return logical.width;
},
get height() {
return logical.height;
},
context,
},
overlay: null,
resize(nextWidth, nextHeight) {
logical = { width: nextWidth, height: nextHeight };
sizeBackingStore();
},
destroy() {
// The OffscreenCanvas dies with the worker — there is no DOM to clear away.
},
};
}
let plot: Plot | null = null;
function init(message: MainToWorker & { type: "init" }): void {
const vars = message.dark ? DARK : {};
const deps = createPlotDeps({
createLayers: (width, height) =>
offscreenLayers(message.canvas, width, height, message.dpr),
createRenderer: createCanvasRenderer,
createStyleReader: () => (name) => vars[name] ?? "",
createTextMeasurer: createCanvasTextMeasurer,
createAxisLabels: createCanvasAxisLabels,
createScheduler: frameScheduler(),
});
plot = new Plot({
deps,
config: { padding: DEFAULT_PADDING, showGrid: true },
size: { width: message.width, height: message.height },
});
plot.applyOptions({
axis: {
x: { ticks: timeTicks({ timeZone: "UTC", locale: "en-US" }) },
y: {
position: "right",
format: priceFormat({ compact: true, locale: "en-US" }),
},
},
shiftVisibleRangeOnNewBar: true,
rightOffset: 4,
});
plot.use(crosshair({ magnet: true }));
const all = fixtureCandles(900);
let revealed = 300;
const price = plot.mainPane.addSeries({
series: candleSeries(),
data: all.slice(0, revealed),
name: "Price",
});
// The tick timer belongs to the worker too — the bars keep arriving even
// while the main thread is jammed.
setInterval(() => {
const next = all[revealed];
if (!next) return;
price.append([next]);
revealed += 1;
}, 400);
}
const report = (message: WorkerReport): void => postMessage(message);
onmessage = (event: MessageEvent) => {
const message: MainToWorker = event.data;
if (message.type === "init") {
// Anything that stops the chart from standing up here — no 2D context,
// a throwing first frame — goes back as a report, so the main thread
// falls back instead of waiting out its timeout.
try {
init(message);
report({ type: "ready" });
} catch (error) {
report({ type: "failed", reason: error instanceof Error ? error.message : String(error) });
}
return;
}
if (!plot) return;
switch (message.type) {
case "pan":
plot.panByPixels(message.dx);
break;
case "zoom":
plot.zoomAtPixel(message.factor, message.x);
break;
case "crosshair":
plot.crosshair({ x: message.x, y: message.y });
break;
}
};apps/examples/src/cases/worker-fallback.ts — the start and its fallback, tested in __tests__/worker-fallback.test.ts.
/**
* 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;
}