---
url: /examples/worker-render.md
description: >-
  Worker rendering: the whole chart runs in a Web Worker on an OffscreenCanvas,
  and falls back to the main thread when the browser or the worker can't.
---

# Worker rendering

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](/guide/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.

```ts
/**
 * 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.

```ts
/**
 * 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`.

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

```
