Skip to content
Muhammet Şafak
tr

How you import Chart.js decides how many kilobytes the visitor downloads

In a real production build, what is the difference between `chart.js/auto` and a selective `Chart.register()` — in kilobytes?

Finding

Selective registration saves 9.7 kB gzip over `chart.js/auto` (67.8 → 58.1 kB, 14.3%). The larger drop is not in the library core but in leaving unused controllers out: a page that registers only the bar chart falls to 46.0 kB — two thirds of auto.

Selective register
58.1 kB -14.3%
chart.js/auto
67.8 kB
Bar only
46.0 kB -32.2%
Island cost of the page
65.3 kB

Method

Four separate `npx astro build` runs against the same repository, the same chart component and the same content file; only the import/register lines of `chart-render.ts` changed between runs. What is measured is the `gzip -9` size of the `dist/_astro/chart-render.*.js` chunk Rolldown emits. The build is deterministic, so one run suffices: identical input produced identical bytes, and each of the four strategies was run twice to confirm.

High confidence Repeated runs, controlled environment, raw data published.
Measured on

measured 46 days ago

Published
Updated

Environment

Astro
7.1.3
Chart.js
4.5.1
Bundler
Rolldown (Astro 7 default)
Node
24.18.0
OS
macOS (Darwin 25.6)
Compression
gzip -9

Technologies

Chart.js Astro Rolldown Preact

To reproduce

npx astro build && gzip -9 -c dist/_astro/chart-render.*.js | wc -c

For years this site had one rule: no required client-side JavaScript. I broke it deliberately when opening the Research section — a multi-series measurement is not readable in a chart without tooltips. But the difference between “broke it” and “cared about it” shows up in whether you measure the bytes you ship.

Chart.js documents two ways. The short one:

import Chart from 'chart.js/auto';

The long one:

import { Chart, BarController, BarElement, CategoryScale, LinearScale } from 'chart.js';
Chart.register(BarController, BarElement, CategoryScale, LinearScale);

Both work. My question was whether the difference is a measurable number in a real build, or a micro-optimisation.

What I measured

I did not set up a synthetic bundler — what I wanted to know is not “how small can Chart.js get” but how much this repository ships. So all four runs went through the site’s own astro build: the same MDX entry, the same chart component, the same Preact island. The only thing that changed between runs was the first ten lines of src/components/research/chart-render.ts.

Gzip size of the Chart.js chunk, by import strategy

All four draw the same chart. The difference is only which controllers and plugins enter the bundle.

kB lower is better Source: astro build output, dist/_astro/chart-render.*.js, gzip -9

Data table
astro build output, dist/_astro/chart-render.*.js, gzip -9
Series chart.js/autobar+line+tooltip+fillerbar+line, no pluginsbar only
gzip 67.8 kB58.1 kB51.2 kB46 kB

The chart is drawn in the browser; the table below carries the same data.

With raw (uncompressed) sizes alongside:

Strategy Raw gzip vs. auto
chart.js/auto 204,965 B 69,466 B —
bar + line + Tooltip + Filler used on this site 172,313 B 59,538 B −9,928 B (14.3%)
bar + line, no plugins 149,793 B 52,378 B −17,088 B (24.6%)
bar only 134,868 B 47,114 B −22,352 B (32.2%)
Raw and gzip move in the same direction but not at the same rate: compression was already squeezing part of the code that got deleted.

What the number says

Selective registration saved 9.7 kB gzip over auto. That is the real figure behind the documentation’s “tree-shaking is supported” — and on its own it is modest: 14.3%.

The actual information is in rows two and three. Dropping the Tooltip and Filler plugins takes another 7.0 kB; a page that never draws a line chart saves 5.1 kB more. In other words, most of the cost is not in the library core but in chart types you never draw. Which is exactly what auto does: it registers all of them.

What the whole page pays

The Chart.js chunk alone is not meaningful; when the island hydrates, the visitor downloads:

Chunk gzip What
chart-render 58.1 kB Chart.js + theme bridge
preact 4.9 kB runtime core
hooks 0.8 kB useRef / useEffect
client 0.8 kB @astrojs/preact client bridge
ChartIsland 0.6 kB the component itself
Total 65.3 kB
This is the island cost of a research page with one chart. The `signals.module` chunk is emitted by the build but the page never requests it — @astrojs/preact loads it only when `data-preact-signals` is present.

The framework side is under 10% of the total (6.5 kB). That does not mean choosing Preact over React would not have mattered here — quite the opposite: react-dom alone approaches the size of Chart.js. But that is a separate measurement and a separate record.

What I did

chart-render.ts opens with these lines and never touches the auto entry:

import {
  BarController, BarElement, LineController, LineElement, PointElement,
  CategoryScale, LinearScale, Tooltip, Filler,
} from 'chart.js';

Chart.register(
  BarController, BarElement, LineController, LineElement, PointElement,
  CategoryScale, LinearScale, Tooltip, Filler,
);

Legend is not on the list: the legend is rendered as HTML on the server. That way it is styled with the site’s own tokens and it stays in place even if no JavaScript runs at all — the same reasoning as the “Data table” disclosure below every chart on this page.

Related posts

Other records

All records

64 goroutines on four cores: Mutex and channel run at the same speed, but the Mutex's p99 is five times higher

When G goroutines on P cores hammer the same shared counter, how many operations a second do sync.Mutex, an owner-goroutine channel and a buffered channel manage, and how do tail latency and fairness of waiting change?

Finding

With 64 goroutines on four cores, sync.Mutex and a one-way channel run at the same speed (7.69 and 7.94 million operations/s) but the Mutex's p99 wait is 5 times higher. On eight cores the Mutex is 2.4 and 4.0 times faster (15.96 million operations/s; the channels 6.65 and 3.97 million); the ratios come from unflagged cells.

measured 5 days ago

Low confidence
Service & load Measurement

One core carries 14,330 OAuth2 requests in Go and 5,152 in PHP-FPM

The same API verifies an RS256 bearer token on every request and then reads or writes one row in PostgreSQL — on one, two and four cores, how much mixed traffic does it carry in Go, PHP-FPM and FrankenPHP worker mode?

Finding

On four cores Go carried 57,321 mixed requests a second, FrankenPHP 25,659 and php-fpm 20,606 — 14,330, 6,415 and 5,152 per core. The number that goes into a capacity plan is not that one but application CPU per request: 66.8, 110.7 and 187.7 microseconds. At saturation FrankenPHP uses only 2.84 of its four cores, against Go's 3.83 and php-fpm's 3.87. The database is not the constraint: on the same four cores PostgreSQL alone writes 68,212 rows a second, above the mixed ceiling of the fastest candidate.

measured 17 days ago

Medium confidence

Laravel's preload curve: 123 files buy eight times what the last 1,912 do

How far can a curated preload take Laravel, and what does each slice cost in start-up time?

Finding

The curve is not proportional to volume. The first 1,592 files — Laravel's own framework — buy 30 ms and add 1.2 seconds to start-up. The next 1,094 Symfony files buy 9.5 ms for free. The **123 files** after that (psr, carbon) buy 15.7 ms, more than the 1,094 before them. And the last 1,912 buy 1.8 ms while adding another 1.2 seconds. So the blanket preload the earlier record measured as a ceiling is the worst point on the curve that is not the origin: stopping at 2,809 files gives 12.77 ms for 1,514 ms of start-up, while 4,721 files ask 2,691 ms to reach 10.96 ms.

measured 43 days ago

High confidence

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind