Skip to content

Shrinking the bundle ​

What a chart costs ​

Before shrinking anything, here is what the preset costs. Each row is a scenario pnpm size measures on every CI run — minified, gzipped, with every @finchart package that row uses bundled in.

What you buildDownload, gzipped
The quick start — one candlestick chart32.0 KB
...plus a volume pane, axes, crosshair, legend and tooltip37.4 KB
...plus four indicators (MA, MACD, RSI, Bollinger)43.7 KB
...plus every drawing tool53.2 KB
The same screen through @finchart/react53.7 KB

React itself is excluded from the last row — a React app already ships it.

These are ceilings, not snapshots. CI fails the moment a bundle crosses one, and today every row measures within 5% of its number. Adding up the per-package budgets in the READMEs gives a larger total: each of those is measured with its peer packages excluded, so the shared core gets counted more than once.

Wiring it yourself ​

Where every byte is budget — an embedded widget, a static chart — you can pick the pieces you need by hand instead of taking the preset (browserDeps()).

browserDeps() is a preset — you stand a chart up in one line, and in exchange everything the preset references ships in the bundle regardless of the options. That is why pointer: false leaves the size identical down to the byte: a bundler cannot see "does this run", only "is this referenced". An option is a door that turns behavior off, not a door that takes bytes out.

The door that takes bytes out is this one — hand the pieces to createPlotDeps yourself:

ts
/**
 * Explicit wiring, the real thing — explicit-wiring.md embeds this file as it
 * is, and `pnpm --filter charts-docs type-check` keeps it compiling.
 *
 * There is one difference from the preset (browserDeps): **only what you
 * import here ends up in the bundle.** The pointer interactions, the dividers,
 * and the gradient painter aren't imported, so the bundler has no way to
 * include them — a runtime flag is a door for behavior, not for bytes; the
 * absence of a reference is the real door.
 */
import {
  candleSeries,
  createCanvasRenderer,
  createCanvasTextMeasurer,
  createPlotDeps,
  frameScheduler,
} from "@finchart/core";
import {
  createDomAxisLabels,
  createDomLayers,
  cssReader,
  PlotBuilder,
} from "@finchart/dom";

const deps = (container: HTMLElement) =>
  createPlotDeps({
    createLayers: (width, height) => createDomLayers(container, width, height),
    createRenderer: createCanvasRenderer,
    createStyleReader: () => cssReader(container),
    createTextMeasurer: createCanvasTextMeasurer,
    createAxisLabels: createDomAxisLabels,
    createScheduler: frameScheduler(),
    // Import interactions the moment you need them — they pay off from the
    // moment they're attached:
    // interactions: pointerInteractions(container),
    // Once you start splitting panes: createDividers: createDomDividers,
  });

const plot = PlotBuilder.create(deps, candleSeries())
  .addDataPoints([
    { x: 0, open: 100, high: 108, low: 98, close: 106 },
    { x: 1, open: 106, high: 112, low: 104, close: 109 },
    { x: 2, open: 109, high: 111, low: 101, close: 103 },
  ])
  .setSize(800, 400)
  .build(document.getElementById("chart")!);

export { plot };

The same candlestick chart stands up lighter than the preset's. What is missing is exactly what you did not import:

Not shippedminWhen you need it
the whole pointer vocabulary~6.4 KBinteractions: pointerInteractions(container)
dragging pane dividers~1.5 KBcreateDividers: createDomDividers
the gradient painter~1 KBcreateCanvasRenderer(surface, { painters: { [LINEAR_GRADIENT]: paintLinearGradient } })
the preset wiring itself~700 B—

Gradients do not break without the painter — an area's fillBottom or a charts/linear-gradient style is demoted to the flat color of its first stop (the fallback contract). When you want to see the gradient, that one painters line above is the door.

What stays anyway — and why it stays ​

Two things stay even when you go all the way down to explicit wiring. Not because they are small enough to forgive — each has a contract as its justification:

  • The decimation default (~1 KB) — M4Decimation, the strategy createPlotDeps falls back to when neither the registration nor the series names one. The moment a derived series (an indicator) attaches, 100k points lean on it. Take it out and "I attached an indicator and it got slow" becomes the default behavior — the frame budget (16.7ms at 60Hz) stands on this default. If you have a strategy of your own, swap it in with createDecimation; that replaces only this fallback — a density (pointsPerPixel) resolves on its own, and a registration's or series' own fields still win.
  • The axis-drag consumer (900 B) — part of the plot's input contract. The input stack is first-class even headless (you feed synthetic input through routeInput — Testing your chart), so it lives on the plot itself, not in the browser wiring.

If this list grows, that is a bug — a passenger must have either a door (an import boundary) or a justification (a contract in writing), and "it's small, it's fine" is not a justification (PRINCIPLES 27).

When to use which ​

  • The preset — starting out, prototypes, a trading screen that uses every interaction. The quick start stands up as written, and gradients, dividers and pinch just work.
  • Explicit wiring — embedded widgets, static charts, anywhere bytes are budget. Import each piece when you need it — the table above is the price list.

The two are two assemblies of the same PlotDeps contract, so you can switch at any time — start on the preset and descend to explicit wiring and your component code does not change (only the deps in PlotBuilder.create(deps, …) moves).