Skip to main content

A live chart at 120 fps, off the main thread

I wanted a live market chart that scrolls at the screen's full frame rate, 60 or 120 frames a second, with thousands of ticks (one tick is one trade: a time, a price and a size) arriving every second, and that keeps doing so while the rest of the page is busy. On the page's own thread (the "main thread", which also runs your app, React, layout and input) that last part is the problem: one 40 ms task and the chart misses frames. So the chart below runs in a Web Worker and draws on an OffscreenCanvas, a second worker owns the WebSocket, and the page only passes its size and the pointer along.

The short version:

  • Hand the canvas to a worker with transferControlToOffscreen(). The worker draws on every frame with its own requestAnimationFrame, and a busy page doesn't slow it down.
  • Give the WebSocket its own worker, which turns each message into columns and passes them on without copying.
  • Keep the ticks in a LiveSeries, fed with pushSeries, for hover lookups and anything else that needs the raw data.
  • Draw one bucket per pixel column, filled as ticks arrive, so a frame costs the same at 10 thousand points or 30 million.

The code is in the pond repo at apps/worker-chart, about 900 lines including a made-up feed, plus a headless browser check and the Node benchmarks behind the numbers below. File paths in square brackets point at it. It builds on the offscreen-stream spike, which measured the method on a MacBook Pro's 120 Hz screen. This example itself has only been run in a headless browser so far; both sets of numbers are in section 8.

Two things to know first
  • pushSeries needs pond-ts 0.76.0 or newer. (The example links the repo's own copy of pond-ts.)
  • pond's charts can't run in a worker yet. LineChart draws inside React and hands the canvas functions, which can't be sent to a worker. So this guide draws with its own code: about 100 lines for the line, plus about 60 for axes and labels. A worker mode for the charts is planned (section 10).
feed ──WebSocket──▶ socket worker ──columns──▶ render worker
decodes each (transferred) LiveSeries + buckets,
message draws every frame on
the OffscreenCanvas
▲ │
size, pointer x │ │ stats, picked tick
page (DOM, tooltip) ────────────┘ ◀──────────┘

1. Why the main thread is the problem​

Everything a page does in JavaScript runs on one thread, and a chart drawn by the page's JavaScript can only update when that thread is free. At 120 Hz a frame comes every 8.3 ms. A chart drawn on the page thread therefore shares those 8.3 ms with everything else the app does, and a long task delays the frame. Moving the drawing into a worker gives it a thread of its own.

The spike measured exactly this on the Mac, at the screen's full resolution, with 100,000 ticks a second and a 10-second window (about 990,000 points), while the page burned 40 ms of every 50:

Chart drawn onFrames a secondGap before the slowest 5% of framesAge of the newest tick
a worker12010.3 ms15 ms
the page thread3347 ms56 ms
a worker, buckets12010.2 ms10 ms
the page thread, buckets6044 ms30 ms

The gap column means the slowest 5% of frames each came at least that long after the frame before; at 120 fps the normal gap is 8.3 ms. The age column is the median, taken at the end of each frame, of how long ago the newest tick on screen was sent.

The first two rows used @pond-ts/charts' own line drawing; the last two the bucket drawing in section 5. All four took ticks in with the spike's code (pushMany plus an internal hook), not pushSeries. Either way the worker chart didn't notice the busy page, while the page's own frame rate fell to 62. The ticks get older on the page thread too, because the socket's messages wait behind the page's work. (The 10 ms gap on the worker is the Mac's normal frame-to-frame jitter: on a quiet page the page-thread chart showed the same.)

2. The page hands over the canvas​

The page creates both workers, connects them with a MessageChannel, and sends the render worker the canvas:

// [src/page.ts], trimmed
const render = new Worker('render-worker.js', { type: 'module' });
const socket = new Worker('socket-worker.js', { type: 'module' });
const toRender = (msg: ToRender, transfer: Transferable[] = []) =>
render.postMessage(msg, transfer);

// The socket worker sends batches straight to the render worker over this
// channel; they never pass through the page.
const feed = new MessageChannel();
const offscreen = canvas.transferControlToOffscreen();
const box = canvas.getBoundingClientRect();
toRender(
{
type: 'init',
canvas: offscreen,
feed: feed.port2,
width: box.width,
height: box.height,
dpr: devicePixelRatio,
columns: Math.ceil(screen.width * devicePixelRatio),
windowMs: windowSec * 1000,
},
[offscreen, feed.port2],
);
socket.postMessage(
{
url: `ws://${location.host}/stream?rate=${rate}&prefill=${windowSec}`,
port: feed.port1,
},
[feed.port1],
);

Three things to notice:

  • The canvas and the port go in the transfer list (the second argument). A transferred object moves to the worker; the page can't use it afterwards.

  • After the transfer the page can't set the canvas's pixel size. The canvas element still sits in the page and CSS still sizes it, but only the worker can set width and height. So the page watches its size and tells the worker:

    // [src/page.ts]
    const sendSize = () => {
    const { width, height } = canvas.getBoundingClientRect();
    toRender({ type: 'resize', width, height, dpr: devicePixelRatio });
    };
    new ResizeObserver(sendSize).observe(canvas);

    A ResizeObserver doesn't fire when only the pixel ratio changes (the window moved to a screen with a different one), so the example also listens to a matchMedia('(resolution: …dppx)') query.

  • columns is the width in device pixels of the screen the page opened on, fixed for the session. Section 5 explains why.

The example uses three things from the browser: OffscreenCanvas, requestAnimationFrame inside a worker, and module workers (type: 'module'). Every current major browser has all three. The last to arrive were Safari 16.4 (March 2023) and Firefox 114 (June 2023); Chrome and Edge had them by version 80. Where a worker has no requestAnimationFrame the example falls back to a 60 Hz timer, but Chrome, the only browser I ran it in, didn't need it, so that path is untested. The numbers in this guide are from Chrome only.

Errors inside a worker are easy to miss, and the chart can look alive with its feed dead. So the example shows any worker error (worker.onerror) and a closed socket in red above the status line, where they stay until the page is reloaded.

3. The socket gets its own worker​

The socket worker does one thing: it reads each WebSocket message and passes the ticks on as one Float64Array per column. The feed sends binary messages, [sendTime, n, droppedSoFar, sentSoFar, time0, price0, size0, time1, …], every 16 ms. droppedSoFar counts the ticks the feed has dropped because the browser fell behind (once the browser has caught up with the history, the feed lets about half a second of ticks wait on the socket, then drops). sentSoFar counts the ticks sent before this message, so the render worker can tell if any went missing on the way; the headless check fails if any did.

// [src/socket-worker.ts], trimmed
self.onmessage = (ev: MessageEvent<{ url: string; port: MessagePort }>) => {
const { url, port } = ev.data;
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onmessage = (m: MessageEvent<ArrayBuffer>) => {
const frame = new Float64Array(m.data);
const n = frame[1]!;
const batch: Batch = {
time: new Float64Array(n),
price: new Float64Array(n),
size: new Float64Array(n),
dropped: frame[2]!,
sentSoFar: frame[3]!,
};
for (let i = 0, o = 4; i < n; i++, o += 3) {
batch.time[i] = frame[o]!;
batch.price[i] = frame[o + 1]!;
batch.size[i] = frame[o + 2]!;
}
// Transfer, don't copy: after this the arrays belong to the render worker.
port.postMessage(batch, [
batch.time.buffer,
batch.price.buffer,
batch.size.buffer,
]);
};
// A dead feed would otherwise look like a quiet market.
ws.onclose = (e) => {
const msg: FeedClosed = { type: 'closed', code: e.code, reason: e.reason };
self.postMessage(msg);
};
};

Whatever format your feed uses (JSON, protobuf, a vendor's binary), this is where it becomes columns. binaryType = 'arraybuffer' matters: the default hands you a Blob, which has to be read asynchronously.

Why a separate worker, when the render worker could open the socket itself? The spike tested it at 1M ticks a second by adding a fixed amount of busy work to every frame and drawing from a plain array (no LiveSeries). On the Mac, a socket on the drawing worker took in every tick until that busy work filled about 70% of the thread, and fell behind from 89%. With the socket on its own worker, every tick got through even at 98%. On a 4-core cloud machine the difference showed much earlier: the drawing worker's own socket fell behind with the thread only 3–4% busy, while with a separate socket worker every tick got through and the newest tick reached the screen in a median of 19–36 ms. Why the shared socket falls behind so early wasn't established, and each machine had only three runs. The extra worker costs about 40 lines, so I'd use it anyway.

4. Ticks into a LiveSeries​

The render worker keeps the ticks in a LiveSeries that holds one window: maxAge drops rows older than the newest tick minus the window.

// [src/render-worker.ts], trimmed
import { LiveSeries, TimeSeries } from 'pond-ts';

const schema = [
{ name: 'time', kind: 'time' },
{ name: 'price', kind: 'number' },
{ name: 'size', kind: 'number' },
] as const;

live = new LiveSeries({
name: 'ticks',
schema,
retention: { maxAge: windowMs },
});

function ingest(batch: Batch) {
const n = batch.time.length;
if (n === 0) return;
const { time, price, size } = batch;
const series = TimeSeries.fromColumns({
name: 'ticks',
schema,
columns: { time, price, size },
});
// The live series keeps these arrays as they are, so nothing may write to
// them after this line. Reading them, as the loop below does, is fine.
live.pushSeries(series);
for (let i = 0; i < n; i++) buckets.add(time[i]!, price[i]!);
buckets.evictBefore(time[n - 1]! - windowMs - buckets.ms);
newestTime = time[n - 1]!;
newestArrival = performance.now();
changed = true;
}

pushSeries takes a batch built by TimeSeries.fromColumns and stores its columns as they are: no row objects, no check per cell. Things to know:

  • The live series keeps your arrays. It doesn't copy them, so don't write to them, reuse them or transfer them again while their rows are in the window. Here each message gets fresh arrays and the render worker never sends them on, so that holds. (Batches of 8 rows or fewer are copied anyway.)
  • Push first, then add the ticks to the buckets. pushSeries stores all of a batch or none of it: it throws on a batch that goes back in time or has a gap in a required column. Adding to the buckets first would put a refused batch on the chart.
  • Retention follows the data's time, not the clock, and runs only inside a push. If the feed stops, the window stops ageing too.
  • Only the render worker imports pond. The socket worker imports types only, so its bundle stays under 1 KB. The render worker's bundle, minified, is about 120 KB gzipped.

Why keep a LiveSeries at all, when the drawing reads only the buckets? Because the buckets throw the ticks away. Hover needs the real tick under the pointer (section 7), and anything beyond the line (an aggregate, a rolling average, a snapshot to export) needs the raw window.

5. Drawing one bucket per pixel column​

A chart that redraws a line through every point gets slower as the window fills. @pond-ts/charts avoids most of that cost on still charts by thinning the points to a few per pixel column and caching the result. A chart that scrolls every frame can't reuse that cache (the data and the time range both change each frame), so every frame thins every point again. In the spike, drawing from a plain array, that held 120 fps up to about a million points (4.3 ms a frame) and fell to 74 fps at 3 million.

So this chart never draws the ticks. It keeps one bucket per device-pixel column of the window, holding the first, lowest, highest and last price that fell in it, and updates the newest bucket as each tick arrives:

// [src/buckets.ts], trimmed
export class Buckets {
constructor(windowMs: number, columns: number) {
this.ms = windowMs / columns; // time covered by one bucket
this.cap = columns + 2; // the window plus the partly visible edges
// k, lo, hi, first, last, n: one Float64Array each, used as a ring
}

add(time: number, value: number): void {
const k = Math.floor(time / this.ms);
if (this.len > 0) {
const i = (this.head + this.len - 1) % this.cap;
if (this.k[i] === k) {
if (value < this.lo[i]!) this.lo[i] = value;
if (value > this.hi[i]!) this.hi[i] = value;
this.last[i] = value;
this.n[i]!++;
return;
}
}
if (this.len === this.cap) this.dropOldest();
const i = (this.head + this.len++) % this.cap;
this.k[i] = k;
this.lo[i] = this.hi[i] = this.first[i] = this.last[i] = value;
this.n[i] = 1;
}
}

A frame draws one line through the buckets: into each column at its first value, down to its lowest, up to its highest, out at its last. A pixel column can't show more than its lowest and highest point, so this should look close to a line through every tick. (I haven't compared the two image by image, and the buckets sit on a fixed time grid while the chart scrolls smoothly, so their edges don't line up exactly with pixels.)

// [src/buckets.ts], trimmed
export function drawBuckets(ctx, b: Buckets, x, y): void {
ctx.beginPath();
for (let j = 0; j < b.len; j++) {
const i = (b.head + j) % b.cap;
const px = x((b.k[i]! + 0.5) * b.ms);
if (j === 0) ctx.moveTo(px, y(b.first[i]!));
else ctx.lineTo(px, y(b.first[i]!));
if (b.n[i]! > 1) {
ctx.lineTo(px, y(b.lo[i]!));
ctx.lineTo(px, y(b.hi[i]!));
ctx.lineTo(px, y(b.last[i]!));
}
}
ctx.stroke();
}

Each tick costs a compare and a store when it arrives, and a frame costs the number of columns, whatever the window holds. In the spike the bucket drawing held 120 fps in every run, at 0.48–0.60 ms a frame on full windows, including a 5-minute window of 29.4 million points (again from a plain array).

Why the bucket count is the screen's width, not the canvas's. The buckets are fixed to one time per column when they're made. If the canvas gets wider, more buckets would need rebuilding from the raw ticks, and reading a window back out of a LiveSeries is slow today (toTimeSeries() takes about half a second at 600,000 rows in Node). Sizing the buckets to the screen the page opened on means the canvas can't outgrow them there: a resize only changes the scale they're drawn at, and a canvas narrower than the screen draws a few buckets per pixel, which costs little. If the window moves to a wider or denser screen, each bucket covers more than a pixel and the line gets coarser until a reload.

6. The frame loop​

The worker draws on every animation frame, whether or not new ticks came, because the time axis moves every frame:

// [src/render-worker.ts], trimmed
// Workers in current browsers have requestAnimationFrame; fall back to a
// timer where one doesn't.
const nextFrame = () =>
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(frame)
: setTimeout(frame, 1000 / 60);

// Ticks arrive every 16 ms; between them, move the right edge on with the
// clock so the chart scrolls smoothly instead of in steps. A batch that
// arrives later than the last one (relative to its times) would pull the
// edge back a few pixels, so the edge waits for the clock to catch up
// instead.
const rightEdge = (now: number) =>
(lastEdge = Math.max(lastEdge, newestTime + (now - newestArrival)));

function frame() {
nextFrame();
const now = performance.now();
if (changed) {
const [lo, hi] = buckets.range()!;
// At least 0.01% of the price, so a flat price (even one that differs
// only by rounding) still gets a range the axis can label.
const pad = Math.max((hi - lo) * 0.05, Math.abs(hi) * 1e-4) || 1;
yLow = lo - pad;
yHigh = hi + pad;
changed = false;
}
const edge = rightEdge(now);
const start = edge - windowMs;
const x = (t: number) => PAD.left + ((t - start) / windowMs) * plotW;
const y = (v: number) => PAD.top + ((yHigh - v) / (yHigh - yLow)) * plotH;

ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels
ctx.fillStyle = COLOR.background;
ctx.fillRect(0, 0, width, height);
drawAxes(start, edge, x, y, plotW, plotH);
// clip to the plot, then drawBuckets(ctx, buckets, x, y)
// the last-price tag reads buckets.newest(), not the live series
// the hover cursor: section 7
}
  • Without the moving edge the chart would step every 16 ms when a batch lands, instead of gliding. With it, a batch that arrives a few milliseconds late would make the edge jump back, so the edge never moves left.
  • The y range is recomputed only when ticks arrive, from the buckets' lows and highs, so it's a few thousand comparisons, not a pass over the ticks.
  • The y range has a floor. A review of this example found that prices differing only by rounding (0.3 and 0.1 × 3) gave a range so small the axis loop's step couldn't move its counter, and the worker hung. The floor fixes that, and the axis loop also stops after 20 lines.
  • Nothing in the frame reads the LiveSeries. The newest price comes from the buckets. Per-frame reads from a live series cost more than they look (section 7).

7. Hover: the page asks, the worker answers​

The tooltip is a div on the page, but the data is in the worker. The page sends the pointer's x; the worker turns it into a time, asks the LiveSeries for the tick at or before it, and sends that tick back. The worker draws the cursor line; the page draws the tooltip, in local time like the axis.

// [src/render-worker.ts]
function pick(px: number | null) {
const plotW = width - PAD.left - PAD.right;
const inside =
px !== null &&
newestTime > -Infinity &&
plotW > 0 &&
px >= PAD.left &&
px <= PAD.left + plotW;
const start = rightEdge(performance.now()) - windowMs;
const event = inside
? live.atOrBefore(start + ((px - PAD.left) / plotW) * windowMs)
: undefined;
hovered = event
? {
time: event.begin(),
price: event.get('price'),
size: event.get('size'),
}
: null;
post({ type: 'hover', tick: hovered });
}

The picked tick stays picked until the pointer moves again, so the cursor scrolls left with the data and the tooltip holds still long enough to read. That also keeps the lookups out of the frame loop, which matters more than it looks, for two reasons. I measured both in Node (bench/lookups.mjs, output in results/lookups.txt):

  • A lookup in the middle of the window walks the stored batches. At 600,000 rows, an atOrBefore took a median of about 4 µs with the rows in one batch, 11–15 µs in 375 batches, and 74–80 µs in 3,750 batches, which is what a 60-second window fed every 16 ms holds.
  • Lookups slow down later pushes. The live series caches every row a lookup returns, and each push that drops old rows rebuilds that cache. At 10,000 ticks a second with a 60-second window, a pushSeries took 0.005–0.007 ms with no lookups, 0.13–0.18 ms with one lookup per push and 0.27–0.32 ms with two.

At 120 fps the frame loop runs about twice per push, so a lookup on every frame would cost 30–40 ms of every second (3–4% of the thread), the lookups and the slower pushes together, for a tooltip nobody can read that fast.

8. What it costs​

On the Mac (from the spike). Chrome on a MacBook Pro M1 Pro with its 120 Hz screen, at full resolution (about 2,260 pixel columns), one run on battery with Spotlight indexing in the background. These used the spike's own code, not this example. Rows 1, 2 and 5 took ticks in with pushMany plus an internal hook rather than pushSeries; rows 3 and 4 drew from a plain array, with no LiveSeries:

SetupResult
1M ticks/s into a LiveSeries in a worker, bucket drawing, 10 s window120 fps, newest tick 11 ms old
Same, socket on its own worker120 fps, 12 ms
Bucket drawing alone, full windows up to 29.4M points120 fps, 0.48–0.60 ms a frame
@pond-ts/charts line drawing, scrolling120 fps at 1M points (4.3 ms a frame), 74 fps at 3M
Taking ticks in at 1M/s (pushMany, an internal hook, buckets)75 ms of each second, under 8% of the worker

(The 1M ticks/s window was about 89% full. "Newest tick 11 ms old" is the median age of the newest tick on screen at the end of each frame; it includes up to 16 ms waiting for the next message, and it needs the feed and the browser on one machine's clock.)

This example, in a headless browser. npm run check in a 4-core cloud container, headless Chromium at one device pixel per CSS pixel: 1,200 buckets (the headless screen's width) drawn into a plot 1,096 pixels wide. Headless frame rates are capped at 60 and say nothing about a real screen, so the table shows work, not frames. Each cell is the range over six one-second samples, three of them with the page busy, all with the window full (results/check-headless.txt):

Ticks/s, windowTicks heldfromColumns, ms a secondpushSeries, ms a secondBuckets, ms a secondJavaScript per frameAge of the newest tick
10k, 60 s600k2.2–4.33.8–5.40.4–0.80.57–0.69 ms9–13 ms
100k, 30 s3M3.8–5.05.4–7.12.1–2.90.63–0.76 ms10–15 ms
1M, 5 s5M11–2033–4423–310.63–0.81 ms19–27 ms

So at 100,000 ticks a second, taking ticks in (building each batch's TimeSeries, pushSeries and the buckets together) took about 1% of the render worker's time, and at 1M a second 7–9%. A Node run of the same pushes shows where pushSeries' share goes at 1M (bench/retention.mjs, results/retention.txt): with a 5-second maxAge, pushSeries cost 1–4 ms a second while the window filled and 29–67 once rows had to be dropped; with maxEvents holding the same 5 million rows it cost 1–2, and with no retention 0–1. Building each batch with fromColumns cost 10–31 ms a second in every case. So in Node nearly all of pushSeries' cost is maxAge working out which rows to drop, which it does by checking the oldest rows' times one at a time ([PND-AGESCAN]). In every run, the busy page left the chart's frame rate where it was.

9. When LineChart is the better choice​

The worker route trades pond's charts for speed and a free page thread. Use LineChart instead when:

  • The page isn't busy, and the window is small or updates can wait. LineChart draws a TimeSeries, so live data reaches it as a snapshot (toTimeSeries(), usually through useSnapshot), and copying that snapshot, not drawing it, is what limits a live LineChart. In the spike a snapshot on every frame was too slow for 120 fps somewhere between 10,000 and 60,000 points. useSnapshot refreshes at most 10 times a second by default, which suits a chart that doesn't need to glide (see Rendering large time series).
  • You want what the charts give you: axes and formats, themes, several layers and series, annotations, zoom, cursors and readouts, and React. Everything in this example beyond the line is hand-written.
  • The chart is mostly still. Buckets suit a window that scrolls; they are fixed to one zoom level, so zooming or panning needs the raw ticks again. A still LineChart thins and caches its points, and the same guide shows it panning at about 90 fps at a million points.

Reach for the worker when the page thread is genuinely busy (a heavy React app, big tables, other charts), when the window holds millions of points that scroll every frame, or when steady 120 fps matters more than the charts' features.

10. What's coming​

These are planned in pond and would shrink this example. The bracketed names are tasks in pond's PLAN.md.

  • A worker mode for the charts ([PND-OFFSCREEN]). Today the charts can't move into a worker: each layer hands the canvas a function with its data built in, the drawing functions aren't exported, and hover and CSS themes read the page. When it lands, the render worker would draw a pond chart instead of section 5's code.
  • A streaming draw for scrolling charts ([PND-STREAMDRAW]): buckets like section 5's, filled as ticks arrive, either as a charts drawing mode or as a live layer (not decided yet).
  • A faster snapshot ([PND-LSNAP]): a toTimeSeries() that copies the stored columns instead of rebuilding rows. In the spike, copying the columns of 600,000 rows took 16 ms in Node, against about half a second for today's toTimeSeries(). Rebuilding the buckets after a resize or a zoom would then cost one copy of the window.
  • Pushes that don't slow down after lookups ([PND-LATCACHE]), and lookups that don't walk every batch ([PND-LIVELOCATE]): the two costs in section 7.
  • A cheap clear() ([PND-LIVECLEAR]): see "Reconnecting" below.
  • A cheaper maxAge ([PND-AGESCAN]): finding the cutoff by binary search instead of row by row, the cost in section 8.

11. Sharp edges​

  • No limit between the workers. The socket worker forwards every message the moment it arrives. If the render worker fell behind, the messages would queue in the browser rather than the feed dropping ticks. It never fell behind in these runs; a real app might count unanswered batches and drop or merge when there are too many.
  • Gaps are drawn as straight lines. An empty column is skipped, so the line joins its neighbours. When the feed drops ticks, the hole looks like a straight stretch of data. Break the line where k jumps if your feed can have holes.
  • Reconnecting needs a new series. A new connection resends history that's older than the rows already held, and pushSeries refuses it. Make a new LiveSeries and new buckets rather than calling live.clear(): on this series clear() builds an object for every row it drops, and took 3.2 s at 600,000 rows and 12.4 s at 5 million in Node, against 0.1–0.2 ms for a new series (bench/clear.mjs, results/clear.txt). The example shows that the feed closed and stops there.
  • The edge keeps scrolling when the feed stops. That's honest (time is passing) but it can hide a dead feed. The example also shows the closed socket; for a feed that stays open but goes quiet, watch the ticks-per-second figure, not the motion.
  • The age figure needs one clock. It compares the feed's timestamp with the browser's clock, which only works with both on one machine.
  • Fonts. The worker's canvas can't use web fonts the page loaded through CSS; the example uses system fonts. Load a FontFace in the worker if you need one.
  • The y range jumps with every new high or low. Ease it if that bothers you.
  • Not covered: several series, zoom and pan, touch, browsers other than Chrome for the numbers, and this example on a real 120 Hz screen (the spike's code ran on one).

12. Running it yourself​

git clone https://github.com/pond-ts/pond && cd pond
npm install && npm run build --workspace=pond-ts
cd apps/worker-chart && npm install
npm start # http://localhost:8787/?rate=10000&window=60

PORT=9000 npm start uses another port. Tick the "busy page" box and watch the chart's frame rate stay put while the page's falls. npm run check runs the headless check (after npx playwright install chromium, or with CHROMIUM_PATH set to a Chrome or Chromium you already have); RATE=100000 WINDOW=30 npm run check tries a heavier feed. npm run bench runs the Node benchmarks behind sections 7, 8 and 11. CI doesn't build the example, so the check is how it's verified.

For the pieces on their own: live series, pushSeries, live charts with @pond-ts/charts and rendering large time series. The spike's full report has the Mac runs; this example's headless and Node runs are in apps/worker-chart/results.