Code-Splitting Entry Points for Libraries
Split a library into entry points consumers can load independently: choosing boundaries, avoiding shared-chunk duplication, and measuring what each entry really costs.
A consumer imports a date formatter and their bundle grows by 40 kB, because the root entry pulls in a locale table they will never use:
one symbol imported: 41.2 kB gzipped
41209 dist/locale-data.mjs
2104 dist/format.mjs
Splitting the package into entry points a consumer can address individually turns that into 2 kB for them and leaves the locale data available to everyone who does need it.
Root Cause
A single-entry library forces every consumer through one module graph. Tree-shaking removes unreachable exports, but a module that is genuinely reached — because the entry imports it, or because something in the chain does — stays, and anything with a side effect anywhere in that chain stays regardless.
Separate entry points break the chain at the manifest level. my-lib/format and my-lib/locale are different graphs from the resolver’s point of view, so a consumer who imports only the first never has the second in their build at all — no analysis required, no purity assumptions needed.
Minimal Reproduction
// src/index.ts — everything reachable from one entry
export { formatDate } from "./format.js";
export { LOCALES } from "./locale-data.js"; // 40 kB of tables
export { parseDate } from "./parse.js";
printf 'import { formatDate } from "@scope/my-library";\nconsole.log(formatDate(new Date()));\n' > probe.ts
npx esbuild probe.ts --bundle --format=esm --minify --outfile=out.mjs
gzip -c out.mjs | wc -c
41288
Forty kilobytes for a function that formats a date, because locale-data.js is reachable from the entry the consumer imported.
Boundaries Worth Splitting Along
Step-by-Step Fix
1. Build one output per entry point
// tsup.config.ts
export default defineConfig({
entry: {
index: "src/index.ts",
format: "src/format.ts",
locale: "src/locale-data.ts",
parse: "src/parse.ts",
},
format: ["esm", "cjs"],
dts: true,
splitting: true, // shared internals emitted once, not duplicated
clean: true,
});
splitting: true is what prevents the obvious failure of this design: without it, a module imported by two entry points is copied into both, so a consumer importing both downloads it twice.
2. Expose each entry in the manifest
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.mjs" },
"./format": { "types": "./dist/format.d.ts", "import": "./dist/format.mjs", "require": "./dist/format.cjs", "default": "./dist/format.mjs" },
"./locale": { "types": "./dist/locale.d.ts", "import": "./dist/locale.mjs", "require": "./dist/locale.cjs", "default": "./dist/locale.mjs" },
"./parse": { "types": "./dist/parse.d.ts", "import": "./dist/parse.mjs", "require": "./dist/parse.cjs", "default": "./dist/parse.mjs" }
}
}
Each entry needs its own types condition. Without it the subpath resolves at runtime and appears untyped to the consumer’s compiler, which reads as the split being broken.
3. Keep the root entry, and decide what it contains
// src/index.ts — the convenient entry, minus the heavy parts
export { formatDate } from "./format.js";
export { parseDate } from "./parse.js";
// locale data is deliberately NOT re-exported here
Removing something from the root is a breaking change, so it belongs in a major release with the subpath documented as the replacement. Adding the subpaths first, in a minor, gives consumers somewhere to move to before the root shrinks.
4. Measure each entry point separately
for entry in "" "/format" "/locale" "/parse"; do
printf 'import * as m from "@scope/my-library%s";\nconsole.log(Object.keys(m).length);\n' "$entry" > probe.ts
npx esbuild probe.ts --bundle --format=esm --minify --outfile=out.mjs >/dev/null 2>&1
printf '%-22s %s bytes gzipped\n' "@scope/my-library$entry" "$(gzip -c out.mjs | wc -c)"
done
@scope/my-library 2913 bytes gzipped
@scope/my-library/format 1104 bytes gzipped
@scope/my-library/locale 38402 bytes gzipped
@scope/my-library/parse 1622 bytes gzipped
Publishing that table in the README is the difference between a consumer guessing and a consumer choosing.
HAZARD PREVENTION
Symptom: After splitting, a consumer importing two entry points reports a larger bundle than before.
Root cause:
splittingwas left disabled, so an internal module used by both entries was duplicated into each output rather than emitted once as a shared chunk.Fix: Enable splitting for the ESM build, and verify by checking that the output directory contains chunk files referenced by more than one entry. Note that CommonJS output cannot be split the same way — for CommonJS consumers, duplication across entry points is unavoidable, which is one more reason to keep the shared internals small.
Verification
# each entry resolves, in both formats
for p in "" "/format" "/locale" "/parse"; do
node --input-type=module -e "await import('@scope/my-library$p')" && echo "esm ok: $p"
done
# shared internals were emitted once
ls dist/chunk-*.mjs 2>/dev/null && echo "shared chunks present" || echo "no shared chunks — check splitting"
# and every entry is typed
npx attw --pack .
Expected: an esm ok: line per entry, at least one shared chunk if entries share internals, and a clean attw table covering every subpath.
Edge Cases / Gotchas
- Shared chunks add a file per graph. For a consumer bundling everything anyway this is neutral; for one loading modules natively it is an extra request, which argues against splitting a small package.
- CommonJS output cannot share chunks the same way. Duplication across entries is expected there, so measure the ESM numbers when reasoning about consumer bundle size.
- Every entry point is a permanent commitment. Adding one is a minor release; removing one is major, and a mechanical split creates commitments nobody asked for.
- Side effects belong to their own entry. A module that registers something on import should be its own entry point so importing it is an explicit act by the consumer.
- The root entry is still the default experience. Most consumers will import it without reading the documentation, so what it contains matters more than any subpath.
Frequently Asked Questions
Is splitting a library the same as splitting an application?
No. An application splits to defer loading; a library splits so a consumer’s bundler can exclude what they never import. Lazy dynamic imports inside a library are usually the wrong tool.
Where should the boundaries go?
Along lines where consumers genuinely differ — optional features, large data, framework adapters — not along internal architectural layers.
What is a shared chunk and why does it matter?
A module used by two entry points, emitted once and referenced by both. Without splitting it is duplicated into each output.
Do split entry points need separate declaration files?
Yes, each with its own types condition, or the subpath resolves at runtime and appears untyped.
How many entry points is too many?
As many as you can document and test — each is a public commitment that needs a types condition and a place in the verification loop.
Related
- Optimizing Bundle Size for Frontend Libraries — the measurement practice this fits into.
- Subpath Exports and Deep Import Patterns — designing and naming the entry points this creates.
- Eliminating Barrel File Anti-Patterns — the root entry whose contents decide the default experience.