Shrinking Published Package Size with sideEffects and files
Your published npm tarball is far larger than the code consumers actually use, and bundlers cannot tree-shake it. This page shows how the files allowlist and sideEffects flag cut tarball size and unlock dead-code elimination for consumers.
Exact symptoms and error messages
npm pack --dry-run reports a tarball with source, tests, and configs, and a consumer's bundle analyzer shows your whole library included even when they import one function:
npm notice === Tarball Contents ===
npm notice 40.1kB src/index.ts
npm notice 12.3kB test/index.test.ts
npm notice 2.1kB tsconfig.json
npm notice 88.4kB dist/index.mjs
npm notice === Tarball Details ===
npm notice package size: 210 kB
Root cause analysis
Two separate problems inflate consumers. First, without a files allowlist npm ships everything not in .npmignore, so source and tests leak into the tarball. Second, without sideEffects: false a bundler must assume importing any module might have observable side effects and therefore cannot drop unused exports. Both are manifest-level contracts, covered in depth in Understanding package.json Fields.
The two levers work on different consumers. The files allowlist controls the tarball — what every npm install downloads — so trimming it helps everyone, including users who never bundle. sideEffects controls what a bundler can eliminate from an application build: it is a promise that importing a module for one export does not trigger observable behavior elsewhere, which lets the bundler drop the unused rest. A library can have a small tarball and still be un-tree-shakeable if sideEffects is missing, so both matter independently.
Format matters too. Tree-shaking works reliably only on ESM output, because static import/export lets the bundler prove which bindings are unused; CommonJS require is dynamic and defeats the analysis. A library that ships only CJS, or whose ESM build re-exports through a barrel file that touches every module, hands the consumer a bundle they cannot shrink no matter how correct the sideEffects flag is.
Resolution and configuration patch
Restrict what publishes and declare that your modules are side-effect-free (or list the few that are not):
{
"files": ["dist"],
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
If a module does have side effects (a CSS import, a polyfill), list it explicitly: "sideEffects": ["./dist/polyfill.js", "*.css"].
Audit what actually ships and how much of it a consumer can drop. npm pack --dry-run shows the tarball, and a quick import probe through a bundler shows the tree-shaken cost:
# What downloads on install
npm pack --dry-run
# Approximate the tree-shaken cost of a single import
echo "export { one } from 'your-lib';" > probe.mjs
npx esbuild probe.mjs --bundle --minify --format=esm | wc -c
If importing one function still pulls in the whole library, a side-effectful module or a CJS-only build is defeating elimination — fix the format or the sideEffects list, not just the tarball.
CLI validation and debug commands
# See exactly what will ship
npm pack --dry-run
# Confirm only dist is included
tar -tf $(npm pack 2>/dev/null) | sed 's|package/||'
# Verify a consumer can tree-shake one import
echo "import { one } from 'your-lib';" > probe.ts
Prevention and CI guardrails
- Always ship a
filesallowlist rather than relying on.npmignoredenylists. - Keep
sideEffects: falseaccurate — a false declaration silently breaks consumers who rely on an import's effect. - Add a CI check on packed tarball size to catch regressions.
- Run
npm pack --dry-runin review so tarball contents are visible in the PR.
Barrel files and the tree-shaking trap
A single index.ts that re-exports every module — a barrel file — is convenient but is one of the most common reasons a sideEffects: false library still bundles whole. If any re-exported module runs code at import time, or if the bundler cannot prove otherwise, importing one symbol through the barrel can drag in siblings.
Keep barrels pure re-exports with no top-level side effects, and prefer named exports over export * where a module might have initialization code. For libraries with many independent entry points, expose subpath exports (your-lib/feature) so consumers can import a slice directly and skip the barrel entirely — the surest way to guarantee a small footprint regardless of how aggressive the consumer's bundler is.
Measuring size as a CI budget
Size regressions creep in one dependency at a time, so make the packed size a tracked number rather than something you notice when a user complains. A CI step that measures the tarball and the tree-shaken cost of a representative import turns a silent regression into a failing check.
- run: |
SIZE=$(npm pack --dry-run --json | jq '.[0].size')
echo "tarball: $SIZE bytes"
test "$SIZE" -lt 60000 # fail if the tarball crosses the budget
Pair the tarball budget with an import-cost probe so both dimensions are covered: the download every consumer pays, and the bytes a consumer's bundle keeps after tree-shaking. When either crosses its threshold, the PR that introduced it is where the conversation happens — not a support ticket months later.
Frequently Asked Questions
Does sideEffects: false change my library's runtime behavior?
It does not change your code — it is a promise to bundlers that importing a module without using its exports can be safely dropped. If that promise is false (e.g. a module registers a global), list the exception instead.
files vs .npmignore — which should I use?
Prefer files. An allowlist fails safe: anything you forget is simply not published. A denylist fails open, so a new file you forget to ignore ships by accident.
Why can't a consumer tree-shake my CommonJS build?
Tree-shaking relies on static ESM import/export to prove which bindings are unused. CommonJS require is dynamic, so bundlers keep the whole module to be safe. Ship an ESM build (alongside CJS) so consumers can eliminate dead code.
Does a barrel file hurt bundle size?
It can. If the barrel re-exports modules with import-time side effects, importing one symbol can pull in the rest. Keep barrels as pure re-exports, or expose subpath exports so consumers import a feature directly.
Related
- Understanding package.json Fields — the files and sideEffects fields that control tarball size and tree-shaking.
- Bundling and Build Tooling for Libraries — the overview for library build configuration.