DocumentationPerformance & Operations

Performance and operations

This is the production guide for a chart that stays open for a market session. It separates three concerns that are easy to confuse: keeping the frame path responsive, bounding retained data, and releasing resources when a view goes away.

What the engine already bounds

  • Multiple series.update(...) calls before the next animation frame are coalesced into one render frame.
  • The series pass and autoscaling read only the visible range of a series, not every bar in history. Study recomputes are the exception: they walk the whole history once per frame that carries a tick.
  • withBarCache has LRU limits for both cache entries and total cached bars.
  • chart.destroy() cancels its pending animation work, disconnects its resize observer, removes chart listeners and canvases, detaches primitives, and drops its event listeners.

Those controls do not make the chart’s data history finite. A chart owns its source bars and the series generated for indicators, so an app that appends forever retains history forever.

Keep a fixed live-data window

Choose a bar budget that matches the longest lookback users need, then retain that many bars in application state. Start conservatively, for example 5,000 bars, and measure on the target devices with the intended indicator and pane set.

Use series.update for every forming-bar tick. Only replace the full series when a newly closed bar pushes the retained window over its limit:

const MAX_BARS = 5_000;
let retained = initialBars.slice(-MAX_BARS);
series.setData(retained);
 
function applyLiveBar(bar) {
  const previous = retained[retained.length - 1];
 
  if (previous?.time === bar.time) {
    // The forming bar stays on the incremental path.
    retained[retained.length - 1] = bar;
    series.update(bar);
    return;
  }
 
  retained.push(bar);
  if (retained.length <= MAX_BARS) {
    series.update(bar);
    return;
  }
 
  // This happens only on a new-bar boundary, never for every tick.
  retained = retained.slice(-MAX_BARS);
  series.setData(retained);
}

setData intentionally replaces the full series, so it can rebuild derived indicator data. Do not use it for forming-bar ticks. Keep the latest source bar in your own state too: series.getData() returns a newly allocated array and is useful for inspection, not as a per-tick state store.

⚠️

Infinite history paging and a fixed in-memory window are different policies. When the user pans beyond the retained window, fetch that page from the feed or from an application cache. Do not let prependData grow the live chart without an explicit upper bound.

Plan for indicators and panes

Each indicator holds output aligned with its source bars. A deep retained range therefore costs more in a chart with several indicators or multi-plot studies than in a bare price chart. calcTail can reduce the calculation work for a forming bar (sixteen built-ins implement it from 2.5.8 and twenty-three from 2.6.0, and a custom study can), but it is not a data-retention policy: output arrays still follow the length of the retained source history.

Keep the live layout purposeful. Remove hidden studies that are no longer needed, avoid attaching expensive profile or order-flow views to every chart in a large grid, and profile the exact configuration users will keep open.

Every pane also owns a base canvas and an overlay canvas. Canvas backing storage grows with the square of device pixel ratio:

approximate bytes per pane = 2 canvases * CSS width * CSS height * DPR^2 * 4

For example, a 1,600 by 900 CSS-pixel pane at DPR 3 needs about 104 MB of bitmap backing storage before browser overhead. That is not a leak, but it is a real working-set cost. Cap DPR where sharpness beyond 2x is not worth the memory:

const chart = createChart(el, {
  pixelRatio: () => Math.min(window.devicePixelRatio || 1, 2),
});

Close every owner

The chart does not own your network feed, timers, or host callbacks. On route change or component unmount, stop those owners before destroying the chart:

const stopBars = live.subscribeBars(request, applyLiveBar, { seedFrom: retained[retained.length - 1] });
const stopViewport = chart.on('pan', saveViewport);
 
function disposeLiveChart() {
  stopBars();
  stopViewport();
  live.close();
  chart.destroy();
}

For OpenAlgoWsFeed, keep the function returned by onLtp or onDepth, call it on teardown, then call ws.close(). close() is intentional, clears socket state and cancels reconnect and heartbeat timers. The same rule applies to host setInterval, setTimeout, DOM listeners, chart event subscriptions, drawing controllers, link groups, and application stores that hold a chart reference.

Cache memory is a separate budget

The bar cache avoids repeated network loads. It does not limit bars currently held by a chart. Set its limits independently and inspect its counters:

const feed = withBarCache(source, {
  ttlMs: 60_000,
  max: 16,
  maxBars: 50_000,
});
 
console.log(feed.stats());
// { entries, bars, hits, misses, evictions }

See Bar Cache for cache freshness and persistent-store responsibilities.

Measure the actual session

Run the repository checks against a fresh library build:

npm run build
npm run bench
npm run soak

npm run bench measures indicator calculation cost and fails unless a burst of live ticks between two frames recomputes each indicator exactly once. npm run soak requires Node’s exposed garbage collector and checks both repeated create/destroy cycles and a fixed-bar live session. To model a 6.25-hour session at four ticks per second, use 90,000 ticks:

$env:SOAK_TICKS = '90000'
npm run soak
SOAK_TICKS=90000 npm run soak

For browser verification, record a performance trace and heap snapshots with a constant bar count. Rising collected heap while source data remains fixed calls for retained-object investigation. An uncollected reading can include temporary allocation, including a tick arriving between collection and measurement. A growing retained-data count also needs a retention policy. Check canvas memory separately, especially on high-DPR displays and charts with many panes.

Frame budgets per bar count

The render bench times three paths in real Chromium at 10,000, 50,000 and 200,000 bars, on the canvas2d and the webgl2 backend: a pan with about 200 bars in view, a frame with every loaded bar in view, and a forming-bar tick on a chart carrying ten studies. Each step is timed from the input to painted pixels, and a p95 over its budget fails the run. CI runs it on every change, in a job of its own.

npm run build
npm run bench:render

Budgets are set from a measured p95 with a margin for slower machines, so they catch a path that gets several times slower rather than describing your device. The measured numbers, the margin and how the bench keeps a fast result honest are in the performance notes. Each release from 2.5.8 on also publishes the bench measured against the release before it, on one machine in one session, on the Benchmarks page.

Repeatable browser endurance

The repository provides a real Chromium Canvas2D workload with two charts, 2,000 bars per chart and ten forming-bar updates per second. It records frame and pointer timing, actual price-plot changes, collected heap, DOM counters and create/destroy retention. Fixed thresholds fail the process when a gate fails.

npm run build
npm run endurance:browser -- --duration-seconds 1800 --sample-seconds 30

Each run serves an immutable bundle snapshot and records its hashes, runtime version, machine, browser, renderer, viewport and DPR. Retained-memory sampling briefly pauses synthetic ticks while pending rendering settles and collection finishes; those pauses remain included in elapsed time and frame statistics. Ordinary allocation pressure is recorded separately.

A nightly workflow runs this 30-minute workload, the soak at 90,000 ticks and the render bench on the latest source, and keeps every report. Require a completed report and inspect its start/end screenshots before citing the result. A 30-minute synthetic run does not establish full-session, live-broker, mobile, WebGL or other-device performance. See the harness, gates and limitations for extended workloads and interpretation.

Current runtime tradeoff

Live updates are coalesced to animation frames, and from 2.5.8 a source-bar update or a study recompute repaints only the panes it changed (plus the panes that show the corner clock or a bar countdown, so those keep moving). The visible-range data path keeps drawing bounded by what is on screen, while retained history and the number of indicator outputs still determine calculation and allocation cost.

The endurance workload shows how much. Changing only the bars per chart, with 150 bars in view, two charts and five studies each, the 2.5.5 build recorded:

Bars per chartFrame p95Pointer p95Result
2,00017 ms28 msevery gate passed
10,000134 ms137 msframe and update-delivery gates failed
50,000717 ms738 msframe, pointer and update-delivery gates failed

Those figures come from one machine (an 8-core desktop CPU, headless Chromium, Canvas2D, DPR 1) with other work running, so read them as a trend rather than a budget for your device. The recorded results give the commands and the full conditions.

For a sustained chart, the practical order is: bound source history, keep the forming bar on update, cap canvas density where needed, remove unused panes and studies, then measure a representative session.