Skip to content

Latest commit

 

History

History
139 lines (105 loc) · 6.59 KB

bundle-composition.md

File metadata and controls

139 lines (105 loc) · 6.59 KB

Bundle composition

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).

Using it

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 only

report 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.

How the attribution works

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:

  1. 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 .map finds nothing.
  2. Decodes the map's base64-VLQ mappings and charges each generated byte range to the source its segment names, with source-less segments inheriting the previous one.
  3. 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.

Tree-shaking and duplicates

@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.

Snapshot

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

Where the bytes go

Update (Issue #387): @heroicons/react (39.1 KB / 2.5% share) was completely removed from the client bundle by migrating all usages to tree-shakeable lucide-react icons. 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).

The Stellar SDK

@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.

Dependencies shipped at more than one version

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.

Files Turbopack does not map

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.