Skip to content

docs: what a CommonJS consumer pays for the root entry, and what fixes it - #41

Merged
jackmisner merged 2 commits into
mainfrom
docs/cjs-bundle-cost
Oct 23, 2026
Merged

jackmisner merged 2 commits into
mainfrom
docs/cjs-bundle-cost

Conversation

@jackmisner

Copy link
Copy Markdown
Owner

What this is, and what it isn't

The investigation this came from recommended against the change it was investigating. There is no code here.

The question was whether to engineer away a ~26 KB penalty that a CommonJS consumer of the root entry pays. Measured, the penalty is real but lands almost entirely where CommonJS consumers aren't, and every in-package fix measured worse than the one-line change the affected consumer has to make anyway. What's left worth doing is telling them which line that is.

The measurements behind it

require static import await import() destructured await import() namespace
esbuild 27,788 1,358 ~28,100 ~28,100
Rollup 27,853 323 464 27,561
Rolldown (Vite 8) 27,512 994 27,867 27,866
webpack 5 1,109 469 ~2,350 ~2,349

Per entry, require vs static import:

entry esbuild webpack 5
root +26,430 B +640 B
./react +2,695 B +471 B
./server +389 B +202 B

Three things follow, and they are why this PR has no code:

  1. "CommonJS can't be tree-shaken" is false for webpack 5. It produced 1,109 bytes from a 54,621-byte CJS module. The 20× is an esbuild/Rollup/Rolldown property, not a property of the format.
  2. ./server — the one entry where CommonJS consumers are realistic — costs 87–204 gzipped bytes, and can't be improved: a static import already retains ~89% of it, because normalizeUtmParams genuinely needs the sanitizer, PII filter and defaults.
  3. To get hit you need all three of bundling for a browser, writing require(), and importing from the root barrel. Vite, Next, Remix, Astro, Rollup, esbuild and webpack 5 all resolve the import condition for a browser target by default.

Why the note earns its place

A consumer who notices the root entry is heavy reaches for await import(), and on three of four bundlers it buys nothing — on webpack it is worse than require, because it forces a separate chunk. They would measure no improvement and conclude the library is simply fat. Only a static import reliably helps, and nothing said so.

What changed

  • Installation: a paragraph on what a require() of the root costs, per bundler, that await import() is not a dependable substitute, and that a static import at the call site is.
  • Platform Support → Node.js: unbundled Node loads the whole entry file either way, so the bundle figures do not apply there at all — the cost is a one-off ~52KB parse, or ~6KB for /server.
  • A correction to the /server size comparison, which gave "roughly 5KB against the root entry's 52KB" without saying those are raw files in the package rather than what a consumer ships — and the server figure was 6KB.

Options rejected, with the evidence

  • tsup splitting for CJS. Works, and check:package passes fully green, which is what makes it a trap. Measured: root CJS +22%, /react CJS +57%, /server CJS +56%, ESM roughly neutral; only the tarball improves (−24%). splitting: false tree-shakes each entry independently; splitting hoists the union into chunks and nothing can be dropped across a chunk boundary.
  • Finer-grained subpaths. Technically effective (capture-only 28.3 → 10.3 KB) but using one requires editing the import line — and a consumer willing to do that can switch require to import and get 3.1 KB instead of 10.3 KB. Six permanent public entry points to offer something 3× worse. Two slices also duplicate their shared common/*, so three slices is worse than the barrel.
  • Dropping the require condition. Fails our own gate: attw reports a live node16 (from CJS) error, and require(esm) only landed in Node 20.19/22.12, so engines.node >= 20.0.0 would have to rise.

Review

Docs-only needs no review under the repo's tiers, but this prose is almost entirely measured numbers, so it got a claim audit — which was the right call. It found that my await import() explanation was backwards: destructuring is what lets Rollup prune (464 B) rather than what stops it (27,561 B read as m.x), "keeps every export" was false for webpack, and the Vite inference was stale since vite@latest depends on Rolldown, not Rollup. I re-verified every corrected figure myself, Rolldown included, before rewriting. One earlier figure I caught myself: /react is ~2.7 KB on esbuild, not the "few hundred bytes" I first wrote.

Follow-ups, not in this PR

  • Module-scope purity in src/config/defaults.ts and src/common/keys.ts. Array spreads and Object.keys() at module scope stop esbuild proving them pure, costing ~1 KB on the ESM path — the path nearly everyone is on. Format-independent, no API change, and worth more than this PR. Agreed as the next one.
  • A latent dual-instance hazard, unrelated to size. A consumer whose dependency tree reaches the package through both conditions gets two copies of defaultProtocol, so setDefaultProtocol applies to half their app. Inherent to dual-format publishing with module-level mutable state.
  • The multi-entry case is unmeasured. Nobody measured a consumer importing both the root and ./react; splitting: false duplicates shared modules across entries, which could be ~13 KB. It wouldn't change a docs-only outcome, so it is flagged rather than guessed at.

A consumer who notices the root entry is heavy will reach for
`await import()`, and it buys nothing: 28,129 bytes against 27,788 for
`require` on esbuild, and worse on webpack, because the namespace object is
destructured at runtime so every export has to be kept. They would measure
no improvement and conclude the library is just fat. Only a static import at
the call site helps, and nothing said so.

The figures name their bundlers deliberately. esbuild and Rollup cannot
tree-shake CommonJS, so a root `require` for one function costs ~28KB
minified against ~1.4KB for the static import. webpack 5 does prune unused
CommonJS exports and the gap there is 640 bytes, so quoting 28KB unqualified
would send a webpack user chasing a problem they do not have.

The subpaths are quoted separately because they behave differently: /server
differs by 202-389 bytes and /react by 471-2,695 depending on the bundler,
since most of what either contains is used regardless.

Also corrects the /server size comparison, which gave 5KB against 52KB
without saying those are raw files in the package. They are not what a
consumer ships, they sat a few lines from a bundle-size discussion, and the
server figure was 6KB.
The claim that `await import()` fails "because the namespace object is
destructured at runtime and the bundler has to keep every export" was wrong
in three ways, and the audit caught all three.

Destructuring is what lets Rollup prune, not what stops it. Measured:
`const { toSnakeCase } = await import(...)` gives 464 bytes against 27,561
for the same import read as `m.toSnakeCase`. The reason was stated backwards
for the one bundler where the advice would have mattered.

"Keep every export" is false for webpack, which prunes most of it either
way; and on webpack `await import()` costs more than `require` anyway,
because it forces a separate chunk, so advice framed around keeping exports
pointed the wrong direction there too.

esbuild and Rolldown do keep everything, but not for that reason: the
namespace survives whether you destructure it or read a property off it,
27,867 against 27,866 bytes on Rolldown.

The paragraph now states which bundlers behave which way instead of
reasoning from a mechanism, names Rolldown rather than inferring Vite's
behaviour through Rollup - vite@latest is 8.3.5 and depends on rolldown, not
rollup - and widens /server to "a few hundred bytes", since esbuild's 422 is
not "a couple".

The Node bullet claimed an import-style difference that does not exist
there: unbundled Node loads the whole entry file either way, so the bundle
figures do not apply at all. It also generalised that Node consumers are not
bundling, which route handlers and bundled Lambdas contradict. It now states
only the parse cost. The /server size sentence says which format its figures
are, and that pruning needs a static import.
@jackmisner jackmisner added the documentation Improvements or additions to documentation label Oct 23, 2026
@jackmisner
jackmisner merged commit 68aacb5 into main Oct 23, 2026
5 checks passed
@jackmisner jackmisner mentioned this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant