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.
Two separate levers control how much of your library a consumer pays for, and they act on different consumers. The files allowlist controls the tarball every install downloads, so trimming it to just the built output helps everyone, including users who never bundle. sideEffects: false controls what a consumer's bundler may eliminate: 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 and neither substitutes for the other.
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.
Restrict what publishes and declare the package side-effect-free:
{
"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 rather than claiming the whole package is pure: "sideEffects": ["./dist/polyfill.js", "*.css"]. Verify the packed contents with npm pack --dry-run so a leaked source directory is caught before publish.
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.
- Ship a
filesallowlist rather than relying on.npmignoredenylists. - Keep
sideEffectsaccurate — a false declaration silently breaks consumers who rely on an import's effect. - Ship an ESM build so consumers can tree-shake at all.
- 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.
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. The barrel becomes a single entry point through which the whole library is reachable, which defeats the per-import elimination that tree-shaking is supposed to provide.
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. The choice between a barrel and subpath exports is really a choice about who controls the footprint: a barrel puts the burden on the consumer's bundler to shake correctly, while subpath exports let the consumer import exactly what they need, which is both more reliable and more explicit about the library's public surface.
Measuring size as a CI budget
Size regressions creep in one dependency at a time, so making the packed size a tracked number rather than something you notice when a user complains is what keeps a library lean over its life. A CI step that measures the tarball and the tree-shaken cost of a representative import turns a silent regression into a failing check on the pull request that introduced it.
- 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 — bundling a single import and measuring the minified output — 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. A size budget also documents the library's intent: a small, focused utility should stay small, and a budget makes that commitment explicit and enforced rather than an aspiration that erodes as features accrete.
Auditing what actually ships
Before optimizing size, see exactly what your package ships, because assumptions are often wrong. npm pack --dry-run lists every file the tarball will contain and its size, which immediately reveals a leaked source directory, a stray test folder, or a large asset you forgot was there. Running it in review makes the published surface visible in the pull request, so a size regression or an accidental inclusion is caught before the version becomes immutable.
# List exactly what will ship, with sizes
npm pack --dry-run
# Inspect the actual tarball contents
tar -tzf $(npm pack 2>/dev/null) | sed 's|package/||'
The audit frequently surfaces the easy wins: a files allowlist that is too broad, a source map referencing files that should not ship, or build artifacts from a tool you no longer use. Fixing these is often a larger size reduction than any tree-shaking improvement, because they remove whole files rather than trimming unused code. Making npm pack --dry-run a habit — in review and in CI — keeps the published surface intentional, so the package ships exactly what consumers need and nothing they do not.
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.
Why can't a consumer tree-shake my library even with sideEffects: false?
Usually a CommonJS-only build, whose dynamic require defeats static analysis, or a barrel file whose re-exported modules run code at import time. Ship an ESM build and keep barrels pure re-exports, or expose subpath exports so consumers import one feature directly.
What's the difference between files and sideEffects for size?
files controls the tarball every install downloads, helping all consumers; sideEffects controls what a consumer's bundler can eliminate, unlocking tree-shaking for those who bundle. A library can have a small tarball and still be un-shakeable if sideEffects is missing — both matter.
How do I stop my package from growing over time?
Track the packed size as a CI budget: measure the tarball with npm pack --dry-run --json and fail the build if it crosses a threshold, plus an import-cost probe for the tree-shaken size. A regression then fails the PR that caused it rather than surfacing months later.
How do I see exactly what my package will publish?
Run npm pack --dry-run, which lists every file and size the tarball will contain, or tar -tzf $(npm pack) to inspect the actual archive. Doing this in review catches a leaked source directory or stray files before the version is immutable.
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.