Back to core workflows Fix dependency resolution Tune package metadata Jump to monorepo patterns

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:

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 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 o
Exact symptoms and error messages — the core idea of this section at a glance.
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.

Root cause analysis Two separate problems inflate consumers. Root cause analysis Two separate problems inflate consumers.
Root cause analysis — the core idea of this section at a glance.

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):

Resolution and configuration patch Restrict what publishes and declare that your modules are side-effect-free (or list the few that are not): Resolution and configuration patch Restrict what publishes and declare that your modules are side-effect-free (or list the few that are not):
Resolution and configuration patch — the core idea of this section at a glance.
{
  "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

CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows. CLI validation and debug commands CLI validation and debug commands in production JavaScript package workflows.
CLI validation and debug commands — the core idea of this section at a glance.
# 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 files allowlist rather than relying on .npmignore denylists.
  • Keep sideEffects: false accurate — 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-run in review so tarball contents are visible in the PR.
Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows. Prevention and CI guardrails Prevention and CI guardrails in production JavaScript package workflows.
Prevention and CI guardrails — the core idea of this section at a glance.
  • Ship a files allowlist rather than relying on .npmignore denylists.
  • Keep sideEffects accurate — 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-run in 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.

Barrel vs subpath An impure barrel bundling everything versus direct subpath imports. Impure barrel • index re-exports all • one import drags siblings • whole lib bundled Subpath exports • your-lib/feature • import one slice • minimal footprint
A pure barrel or subpath exports let consumers import one feature without the rest.

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.

Size budget gate Measure tarball and import cost, fail on regression. measure tarball npm pack size probe import cost tree-shaken bytes fail over budget block regression
A size budget in CI catches a regression on the PR that caused 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 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.

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 s 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
Barrel files and the tree-shaking trap — the core idea of this section at a glance.

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.

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 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 ke
Measuring size as a CI budget — the core idea of this section at a glance.
- 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.

Audit the tarball List files and sizes before publishing. npm pack --dry-run files + sizes spot leaks source, tests, assets trim files intentional surface
npm pack --dry-run reveals leaked files — often a bigger win than tree-shaking.
# 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

Bundling and Build Tooling for Libraries