Packages
Performance
What the numbers are, how they are measured, and how to keep them.
The Numbers
Measured on 4 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) |
37.5 KB | 1 |
ORTEX Charts, candles and volume (createFinancialChart) |
55.7 KB | 1 |
| ORTEX Charts, candles with indicators and drawings | 59.7 KB | 1 |
ORTEX Charts, the full shell (financial + ui) |
76.9 KB | 1 |
ORTEX Charts, a sparkline (lite) |
4.8 KB | 1 |
| TradingView Lightweight Charts 5, standalone production build | 55.3 KB | 1 |
Highcharts Stock 11, highstock.js alone |
129.9 KB | 1 |
| Highcharts Stock 11, with the stock tools and indicators its own demo loads | 193.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 37.5 KB here against 55.3 KB there, and our candles-and-volume build matches theirs to within half a kilobyte while adding a volume pane, a legend, price lines and a crosshair readout. The 45 KB figure quoted in most comparisons is the vendor's own claim rather than a measurement of the file they publish for version 5.
- 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 193.6 KB across nine files. The ORTEX equivalent is 76.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
Headless Chromium at 1400 by 900, the same bars in both libraries, run from the benchmark page in the repository.
| Bars | ORTEX create and first paint | Lightweight Charts 5 | ORTEX live tick | Lightweight Charts live tick |
|---|---|---|---|---|
| 10,000 | 44 ms | 98 ms | 1.6 ms | 0.5 ms |
| 100,000 | 32 ms | 388 ms | 1.8 ms | 4.2 ms |
Read the live-tick columns carefully, because they are not measuring the same thing. The ORTEX figure is one realtime bar update including a synchronous full redraw, about 1.3 ms of which is the drawing. The Lightweight Charts figure is the update call alone, because that library draws on its own animation frame afterwards. At 10,000 bars the comparison flatters Lightweight Charts for that reason; at 100,000 bars ORTEX is faster even carrying the redraw.
Both libraries pan and zoom at the display's 60 Hz with 100,000 bars.
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 32 ms at 100,000 bars against 44 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: a live tick on 100,000 bars with two overlays, an EMA and an RSI went from 8.3 ms to 1.8 ms 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.