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

Where to put an entry point boundary Split along optional features, large data tables, framework adapters and heavy-dependency integrations. Do not split along internal architectural layers, which produces entry points nobody imports separately. Split where consumers differ, not where your code is layered an optional feature some consumers use it, most do not — a clear win a large data table locales, timezones, unit maps — usually the biggest win a framework adapter carries a peer dependency nobody else needs an internal layer nobody imports "my-lib/core-utils" — do not split here

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.

Single graph versus split entries with a shared chunk With one entry point every consumer resolves the same graph including the locale table. With separate entries, a consumer importing only the format entry gets the format module and a small shared chunk, and never resolves the locale data at all. The consumer's import decides the graph one entry point import { formatDate } from "my-lib" format + parse + locale (40 kB) elimination must prove purity of everything in the chain split entry points from "my-lib/format" format + shared chunk (2 kB) locale data is never resolved, so no analysis is needed

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: splitting was 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.



↑ Back to Optimizing Bundle Size for Frontend Libraries