---
url: /guide/workers.md
description: >-
  Render a chart in a Web Worker on an OffscreenCanvas: what moves to the
  worker, how bundlers load it, bridging input, falling back to the main thread,
  and what live updates cost.
---

# 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](/examples/worker-render) 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 `ChartLayers` over the `OffscreenCanvas`, with `overlay: null`.
  There is no overlay, so no DOM labels, tooltip, legend or divider handles;
  axis labels come from `createCanvasAxisLabels`.
* **Style** — a `StyleReader` over 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's `requestAnimationFrame`
  when 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

```ts
const worker = new Worker(new URL("./chart.worker.ts", import.meta.url), { type: "module" });
```

This is the form [Vite](https://vite.dev/guide/features#web-workers) and
[webpack 5](https://webpack.js.org/guides/web-workers/) 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:

1. Without `Worker` or `transferControlToOffscreen`, build the chart on the
   main thread right away.
2. Otherwise make the worker, listen for `error`, `messageerror` and its
   messages, transfer the canvas and send the first message, all inside one
   `try`.
3. 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.
4. 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.
5. If the page goes away first, terminate the worker and stop listening, so a
   late message does nothing.

```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;
}

```

## 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 `postMessage` from the socket's thread to the
  worker is a structured clone, a queued task and a dispatch, before the chart
  does anything with it.
* **`conflated` in the worker saves deliveries, not messages.** It holds the
  latest bar and calls `updateLast` once per frame, but every tick has already
  crossed as a message by then. To send fewer messages, batch on the sending
  side.
* **Without `requestAnimationFrame` in the worker, `conflated` delivers each
  tick at once.** Its default schedule is `frameScheduler()`, 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.
