Skip to content

Packages

Performance

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

Read this page as Markdown, or take the whole documentation in one file.

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.

100,000 bars, timed here@ortex-charts/financial
Loading 100,000 bars, timed here…
Create the chart, set the data, wait for the first paint, and print the number your own browser just produced.

Open this example with its source

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

// 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

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.

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

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.