scripts/bundle-size.mjs already answers how much ships: it sums the client
JS and CSS in .next/static and fails a build that grows the gzip total by more
than 5% and 10 KB against the latest main baseline.
That is enough to catch a regression but not to explain one. When the number
moves, the first question is always "which package?", and answering it meant
building locally and reading chunk filenames. This document and the
composition command answer it from CI instead (#266).
npm run build # without ANALYZE_BUNDLE, composition is unavailable
ANALYZE_BUNDLE=1 npm run build
node scripts/bundle-size.mjs measure # writes bundle-composition-current.json
node scripts/bundle-size.mjs report # size + composition, as the CI summary
node scripts/bundle-size.mjs composition # composition onlyreport is what CI runs, and it now emits a composition table underneath the
size table, with a per-package "change vs main" column. The largest increases
are called out separately, which is usually the whole answer.
The CI job builds with ANALYZE_BUNDLE=1, caches the composition baseline from
main the same way it already did for the size baseline, and uploads
bundle-composition-current.json as an artifact on every run so a reviewer can
diff composition without rebuilding.
Turbopack's minified client chunks name no modules, so bytes cannot be traced
back to their origin from the chunk text. ANALYZE_BUNDLE=1 makes
next.config.ts set productionBrowserSourceMaps, which makes Turbopack emit a
source map per chunk. scripts/bundle-composition.mjs then:
- Resolves each chunk's map through its
//# sourceMappingURL=comment. This matters: Turbopack names a map independently of its chunk (03zi82on67aet.js->31eed2o9wbwkr.js.map), so assuming an adjacent.mapfinds nothing. - Decodes the map's base64-VLQ
mappingsand charges each generated byte range to the source its segment names, with source-less segments inheriting the previous one. - Rolls the sources up to the owning package, first-party code, or the unmapped bucket.
Source maps are opt-in rather than always on because they roughly double build output and add time, and only the bundle job needs them.
Two honest limits:
- Attribution is per source map, not per byte-exact AST, so treat it as a strong signal rather than an accounting. A segment owns everything up to the next segment's column, ignoring column-level mappings inside that span.
- Turbopack maps no CSS chunk and one large runtime chunk, listed under "Files Turbopack does not map" below.
The decoder and attribution are covered by test/bundle-composition.test.ts,
including a check that every byte of a file is accounted for.
@stellar/stellar-sdk is the dependency whose shipped size depends most on
import shape, so it gets its own section in the report: total, share, and the
SDK modules responsible, largest first. A jump in
src/base/generated/curr_generated.js in particular means an XDR-typed import
crept in and pulled the whole currency table along.
Duplicate versions are read from package-lock.json, so a duplicate is caught
even if it happens to be fully tree-shaken away. The report only lists
duplicates that actually reach the browser, because a lockfile is full of
build-tool duplicates that never ship and listing them all buries the signal.
Recorded from a production build on 2026-09-27.
| Metric | Value |
|---|---|
| Static assets measured | 38 |
| Total shipped | 1549.1 KB raw / 450.5 KB gzip |
| Attributed via source maps | 1543.0 KB across 63 buckets |
Update (Issue #387):
@heroicons/react(39.1 KB / 2.5% share) was completely removed from the client bundle by migrating all usages to tree-shakeablelucide-reacticons. This saves 39.1 KB raw (~10.8 KB gzip) with zero duplicate icon libraries shipped.
| # | Package | Bytes | Share |
|---|---|---|---|
| 1 | next |
482.9 KB | 31.3% |
| 2 | @stellar/stellar-sdk |
276.2 KB | 17.9% |
| 3 | @tanstack/query-core |
33.6 KB | 2.2% |
| 4 | tailwind-merge |
26.2 KB | 1.7% |
| 5 | buffer |
23.9 KB | 1.6% |
| 6 | bignumber.js |
19.8 KB | 1.3% |
| 7 | @stellar/js-xdr |
18.1 KB | 1.2% |
| 8 | lucide-react |
11.8 KB | 0.8% |
| 9 | @noble/hashes |
10.4 KB | 0.7% |
| 10 | @radix-ui/react-slot |
9.3 KB | 0.6% |
| 11 | react-hot-toast |
9.2 KB | 0.6% |
| 12 | @noble/ed25519 |
7.8 KB | 0.5% |
| 13 | @floating-ui/core |
7.5 KB | 0.5% |
| 14 | @radix-ui/react-collection |
7.0 KB | 0.5% |
On top of those, 247.1 KB is first-party app code and 196.2 KB sits in files Turbopack does not map (listed at the bottom).
@stellar/stellar-sdk is the largest dependency at 276.2 KB (17.9%), so it is worth knowing which parts of it actually ship:
| SDK module | Bytes |
|---|---|
src/base/generated/curr_generated.js |
99.1 KB |
src/contract/spec.ts |
18.9 KB |
src/base/transaction_builder.ts |
14.0 KB |
src/rpc/server.ts |
12.4 KB |
src/contract/assembled_transaction.ts |
10.0 KB |
src/bindings/sac-spec.ts |
9.6 KB |
src/base/generated/curr_generated.js is the bulk of it: the XDR currency table, which anything touching XDR types pulls in wholesale. Treat it as a floor rather than a regression — the rest of the SDK's footprint tracks which contract and RPC helpers get imported, and a new import of a helper that reaches an XDR type will show up here as a jump in this file.
None. Every dependency that reaches the browser resolves to exactly one version, so nothing is duplicated in the client bundle.
The lockfile does contain 46 packages at more than one version, but all of them are build- or dev-only (eslint and its dependencies, the lightningcss platform binaries, glob and friends). The report separates the two so the lockfile's noise does not read as a bundle problem.
productionBrowserSourceMaps produces no map for these, so their bytes land in the (unmapped files) bucket instead of being attributed:
chunks/0cz1d0mv5g_q7.js(110.0 KB)chunks/0y4d3x61-2xj2.css(85.5 KB)z8ZLnAeyzbGQjyPPPwKry/_buildManifest.js(0.2 KB)z8ZLnAeyzbGQjyPPPwKry/_clientMiddlewareManifest.js(0.5 KB)z8ZLnAeyzbGQjyPPPwKry/_ssgManifest.js(0.1 KB)
That is 12.7% of attributed bytes: a 110 KB framework/polyfill chunk and the compiled stylesheet. If that share moves substantially, treat the per-package numbers as proportions of the mapped remainder rather than of the whole bundle.