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.
@ortex-charts/financialThe 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,
setDataand 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
fromandtoand 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
lookbackhas 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.