# Performance

> What the numbers are, how they are measured, and how to keep them.

Section: Packages. Source: https://ortexcharts.com/docs/performance

## The Numbers

Measured on 5 September 2026 by `scripts/bench/bundle-size.mjs` in the library repository. Our
entry points are bundled with esbuild and gzipped; the other vendors' own published production
files are gzipped by the same code, file by file. Sizes are per entry point rather than per
library, because tree-shaking makes "the library is N kilobytes" meaningless: a page that draws a
line pays for a line.

### Payload

| What a page imports | gzip | Files |
|---|---|---|
| ORTEX Charts, a line chart (`core`) | **32.5 KB** | 1 |
| ORTEX Charts, candles and volume (`createFinancialChart`) | **59.0 KB** | 1 |
| ORTEX Charts, candles with indicators and drawings | **67.3 KB** | 1 |
| ORTEX Charts, the full shell (`financial` + `ui`) | **89.9 KB** | 1 |
| ORTEX Charts, a sparkline (`lite`) | **3.3 KB** | 1 |
| TradingView Lightweight Charts 5, standalone production build | 60.7 KB | 1 |
| Highcharts Stock 13, `highstock.js` alone | 132.6 KB | 1 |
| Highcharts Stock 13, with the stock tools and indicators its own demo loads | 215.6 KB | 9 |
| TradingView Advanced Charts, the licensed drop | 4,757.5 KB uncompressed | 154 |
| ChartIQ, its public demo | 4.4 MB transferred | 65 |

Three comparisons are worth drawing out.

- **Like for like, ORTEX Charts is smaller than Lightweight Charts.** A chart with one series
  costs 32.5 KB here against 60.7 KB there, a little over half for the same job, and none of
  the difference is features it has and we do not. Adding candles,
  a volume pane, a legend, price lines and a crosshair readout takes ORTEX Charts to 59.0 KB,
  which is still under the 60.7 KB of a renderer that draws a series and stops. The 45 KB figure quoted
  in most comparisons is the vendor's own claim rather than a measurement of the file they
  publish.
- **Against a chart with a financial toolbar, the gap is wide.** Highcharts Stock does have one,
  and loading it the way their own Stock Tools demo does costs 215.6 KB across nine files. The
  ORTEX equivalent is 89.9 KB in one.
- **Against the licensed drops it is not close.** Advanced Charts is 154 files and 4.8 MB on
  disk; ChartIQ transfers 4.4 MB across 65 files before a chart appears.

### Rendering

Measured on 2026-09-08 in headless Chromium at 1400 by 900, driven by Playwright inside the
same Linux image the tests run in, with the same chart in every library: candles, a volume
pane and two overlay lines. Three repeats, every repeat on a fresh page, and the cell is the
median. `scripts/bench/bench-linux.sh` reruns it, which is how these numbers stay comparable
between releases rather than between machines.

**Create to first paint**, timed from before the create call to the frame that paints the
data.

| Bars | ORTEX Charts 1.0.21 | Lightweight Charts 5.2.1 | Highcharts Stock 13.0.2 |
|---|---|---|---|
| 10,000 | **40 ms** | 83 ms | 50 ms |
| 100,000 | **33 ms** | 448 ms | 202 ms |

Highcharts Stock runs with its own data grouping left on, which is what it ships with: at
100,000 bars it is drawing far fewer than 100,000 candles, and that is the number its users
get.

**A live tick, to the frame that paints it.** Call the library's update, then wait for the
frame that carries the change to the compositor. Both libraries coalesce drawing onto their
own animation frame, so both are measured through that path: 60 ticks, one per frame, one in
ten opening a new bar and the rest refining the forming one, as a feed does.

| Bars | ORTEX Charts | Lightweight Charts 5 | Highcharts Stock 13 |
|---|---|---|---|
| 10,000 | **33 ms** | 33 ms | 33 ms |
| 100,000 | **33 ms** | 68 ms, and 112 ms at the 95th percentile | 36 ms |

33 ms is the floor of this measurement: two frames of the 60 Hz cadence headless Chromium
paces rendering at. A library sitting on the floor absorbed the tick inside one frame budget
and dropped nothing. At 10,000 bars both do, and there is no difference a user could see. At
100,000 bars ORTEX Charts is still on the floor, Highcharts Stock is a fraction of a frame
over it, and Lightweight Charts is two to five frames late.

An earlier version of this page compared an ORTEX redraw against a Lightweight Charts update
call that only schedules one. That is unlike work: it flattered us at 100,000 bars and
flattered them at 10,000, and it has been withdrawn. Where the same call exists on both
sides, the processor cost is a tie: an update at 100,000 bars is 3.4 ms here and 3.1 ms
there.

ORTEX Charts and Lightweight Charts both pan and zoom at the display's 60 Hz with 100,000
bars. Highcharts Stock holds about 41 Hz.

Advanced Charts and ChartIQ cannot be driven from code in this harness — there is no
installable build — so only their load cost is compared.

_Live example: [benchmark](https://ortexcharts.com/examples/benchmark)_

The example above runs the same measurement in your browser: it creates a chart, sets
100,000 bars, waits for the first paint and prints the number your machine produced. Expect
it to be slower than the headless figure on a laptop on battery, and faster than you expect
on a desktop.

### Why 100,000 Bars Starts Faster Than 10,000

The 33 ms at 100,000 bars against 40 ms at 10,000 is not a typographical error, and it is
worth understanding because it explains the engine's design.

Start-up cost is dominated by the work proportional to the **visible** range, not to the
data. With 100,000 bars the initial view is a smaller share of the data, so fewer bars are
drawn on the first frame, and the per-bar setup is a typed-array copy rather than object
allocation. The remaining variance between the two runs is measurement noise on a small
absolute number.

## How the Numbers Are Produced

Everything above is reproducible rather than asserted.

- **Payload** is measured from a production build of the packages with `bundle-size`, which
  bundles the public entry points and gzips the result. The competitor figures are the
  transferred bytes their own demo pages fetch, read from the network panel.
- **Rendering** is a Playwright script against the benchmark page in the repository,
  headless Chromium, fixed viewport, the same generated bars given to both libraries, timed
  around chart creation, `setData` and the first paint. It is opt-in, so it does not run in
  every continuous-integration job.

Both are in the repository and are re-run when the numbers change.

## What Makes It Fast

- **Typed-array columns.** Series are a struct of arrays, so a data set is a handful of
  allocations rather than one object per bar, and slicing a visible window is free.
- **Visible-range work only.** Renderers receive `from` and `to` and draw between them.
  Autoscale considers only the visible range. Nothing iterates the whole history on a frame.
- **Decimation.** A line denser than the pixels available is reduced to one minimum and one
  maximum per pixel column, so drawing cost stops growing with the data.
- **Layered canvases.** Each pane has a main canvas and an overlay canvas, and the time-axis
  label has its own. Moving the crosshair repaints the overlays only, so a pointer move
  never redraws a series.
- **Legend DOM only on change.** The legend is rebuilt when its content key changes, so a
  60 Hz pan does not churn the DOM.
- **Amortized appends.** Series keep a capacity-doubling backing store and hand out subarray
  views, so a live tick does not copy the columns.
- **Incremental alignment.** When the main timeline grows in place, overlays re-align only
  their tail: O(tail) per tick rather than O(n).
- **Windowed indicator recompute.** An indicator that declares its `lookback` has only that
  window recomputed on a tick, verified equal to a full recompute to within 1e-6.
- **Candles batched by color.** Up candles and down candles are two fill passes, not one per
  bar.

The single largest improvement in the library's history came from the realtime path: the
processor cost of a live tick on 100,000 bars with two overlays, an EMA and an RSI fell by
roughly four fifths when alignment and indicator recompute became incremental.

## Practical Guidance

### Hand Over Columns, Not Objects

```ts
// Fine for a few thousand rows.
series.setData(rows.map((r) => ({ time: r.t * 1000, value: r.v })));

// Better for a large set: build the columns once and hand them over.
const time = new Float64Array(n);
const value = new Float64Array(n);
for (let i = 0; i < n; i++) { time[i] = rows[i].t * 1000; value[i] = rows[i].v; }
series.setData({ length: n, time, value });
```

Columnar input is not copied. For 100,000 rows this is the difference between one allocation
and 100,000.

### Use `update` for Live Data, Never `setData`

```ts
series.update(bar);        // O(1) amortized, keeps every fast path
series.setData(allBars);   // full rebuild, full re-align, full indicator recompute
```

Calling `setData` on every tick is the single most common performance mistake, and it
undoes every incremental path in the engine at once.

The rules `update` relies on:

- A time equal to the last bar replaces it in place.
- A later time appends into the backing store.
- An **earlier** time takes the slow rebuild path, so a feed that emits out of order pays
  for it on every tick. Buffer and sort before handing rows over.

### Do Not Hold On to `columns`

Aligned columns are views into growable buffers. They are valid until the next data change
and not after it. Read what you need and let go.

### Declare `lookback` on Custom Indicators

An indicator without a `lookback` recomputes over all history on every tick. That is correct
and, on a long history with a fast feed, the dominant cost. Declaring the window it actually
depends on turns it into a constant.

```ts
lookback: (inputs) => 10 * Number(inputs.length)
```

Leave it unset only for genuinely cumulative indicators, where the last value depends on the
first bar.

### Watch the Cumulative Indicators

`obv`, `cvd`, `vwap` and `cumvol` recompute fully on every tick by construction. On 100,000
bars with several of them and a tick every 200 ms, they are what will show up in a profile.

### Heikin-Ashi Is Not Incremental Yet

The Heikin-Ashi transform re-runs over all bars whenever the data changes, including on
every live tick. On a long intraday history with a live feed this is measurable. It is a
known gap rather than a design decision.

### Prefer One Chart With Panes Over Several Charts

Panes share one time scale, one interaction layer and one animation frame. Three linked
charts are three of everything.

### Give the Container a Size Before Creating the Chart

A chart created into a zero-height container does its layout work twice: once at zero and
once when the `ResizeObserver` fires. Setting the height in CSS avoids the second pass and
the visible reflow.

## Where the Ceiling Is

Rendering is **Canvas 2D, not WebGL**. That is a real limit and it is worth being precise
about where it bites.

- A few hundred thousand visible points hold 60 Hz with decimation. A decade of daily bars
  is 2,500 rows; a decade of minute bars is about a million rows, of which only the visible
  window is drawn.
- What Canvas 2D does not do is millions of points **all on screen at once** at 60 Hz with
  no decimation — the case SciChart's WebGL renderer is built for. If your product plots
  raw tick data for a whole session with every point visible, this library is the wrong
  choice today.
- A WebGL path for line and candle series behind the same API is on the roadmap. It is not
  implemented.

The other honest limits: there is no worker offload, so computation happens on the main
thread; and there is no progressive rendering, so a very large `setData` is one synchronous
block rather than a series of frames.

## Measuring Your Own Chart

```ts
performance.mark("start");
const chart = createFinancialChart(el, { data: bars });
chart.chart.flush();                       // force a synchronous draw
performance.mark("painted");
performance.measure("first-paint", "start", "painted");
console.log(performance.getEntriesByName("first-paint")[0].duration);
```

`flush()` is the important call: drawing is otherwise deferred to the next animation frame,
so a naive measurement times the setup and misses the paint.

For the realtime path, measure a tick the same way around `series.update` followed by
`chart.flush()`, and compare it against your tick rate. Anything under about 4 ms leaves
headroom at 60 Hz.
