Without deliberate export topology and side-effect declarations, a frontend library author ships what feels like a tightly scoped package — only to discover that consumers are pulling in the entire dependency graph regardless of actual import surface. In webpack 4 and earlier this was universal; in webpack 5, Rollup, esbuild, and Vite the bundler can eliminate dead exports, but only when the library’s package.json actively cooperates. Libraries that omit "sideEffects" or resolve to CJS instead of ESM silently defeat every optimization the consumer’s bundler would otherwise apply.

Prerequisites

Before working through the steps below, confirm your environment meets these requirements:


Canonical Configuration Block

The exports map is the single most impactful configuration in a library’s package.json. Bundlers that do not find an "import" condition fall back to "main" (CJS), losing all static export analysis and therefore all dead-code elimination. Condition keys must appear in priority order: "types" first so TypeScript resolves before bundlers, "default" last as the unconditional fallback.

{
  "name": "@org/ui-core",
  "version": "1.0.0",
  "type": "module",
  "sideEffects": [
    "*.css",
    "src/polyfills/*.ts",
    "src/setup/globals.ts"
  ],
  "exports": {
    ".": {
      "types":   "./dist/types/index.d.ts",
      "import":  "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/esm/index.js"
    },
    "./utils/*": {
      "types":   "./dist/types/utils/*.d.ts",
      "import":  "./dist/esm/utils/*.js",
      "require": "./dist/cjs/utils/*.js",
      "default": "./dist/esm/utils/*.js"
    },
    "./package.json": "./package.json"
  },
  "main":   "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "types":  "./dist/types/index.d.ts"
}

Keep "main" and "module" as fallbacks for bundlers older than webpack 5. The "./package.json" subpath export is required so consumers can read it at runtime without hitting ERR_PACKAGE_PATH_NOT_EXPORTED.


Step-by-Step Implementation

Step 1 — Configure the exports field and dual-format output

Configure the exports field in package.json using the canonical block above. Pair it with TypeScript path-mapping alignment so IDE resolution matches runtime bundler behavior:

// tsconfig.json (library consumer)
{
  "compilerOptions": {
    "moduleResolution": "bundler",   // or "node16"/"nodenext"
    "paths": {
      "@org/ui-core":         ["./node_modules/@org/ui-core/dist/types/index.d.ts"],
      "@org/ui-core/utils/*": ["./node_modules/@org/ui-core/dist/types/utils/*.d.ts"]
    }
  }
}

Expected output when the bundler resolves the "import" condition:

resolved @org/ui-core → ./dist/esm/index.js  (ESM, static analysis enabled)

If you see ./dist/cjs/index.js in bundler output, the condition key order in exports is wrong or the bundler does not support the "import" condition (see compatibility matrix below).

Step 2 — Declare sideEffects accurately

Omitting "sideEffects" is equivalent to "sideEffects": true, which tells every bundler: “every module in this package may mutate the global environment — do not remove any of them.” This single missing field disables tree-shaking for the entire package, even when every export is pure.

For implementing the sideEffects flag correctly, use false for pure utility libraries and an explicit array for anything that ships CSS, polyfills, or global-registering code:

{
  "sideEffects": false
}
{
  "sideEffects": [
    "**/*.css",
    "src/polyfills/index.ts",
    "src/setup/register-custom-elements.ts"
  ]
}

HAZARD PREVENTION: Glob patterns in "sideEffects" arrays are matched against the resolved file path. Using "*.css" matches only CSS files in the package root. Use "**/*.css" to match CSS files at any depth.

Step 3 — Annotate pure factory functions

The /*#__PURE__*/ comment signals to Rollup, webpack, and esbuild that the annotated expression has no side effects and can be removed if its return value is never referenced. Without it, bundlers conservatively retain any function call that could mutate module-level state.

// Retained even when createLogger is never called — bundler cannot prove
// the IIFE has no observable side effects
export const createLogger = (() => {
  return (level: 'info' | 'warn') => console[level];
})();

// Safe for elimination when createLogger is unused in the consumer bundle
export const createLogger = /*#__PURE__*/ (() => {
  return (level: 'info' | 'warn') => console[level];
})();

Similarly, class methods decorated with TypeScript decorators emit helper calls that bundlers cannot statically analyze. Annotate the class declaration itself:

export const MyComponent = /*#__PURE__*/ (() => {
  class MyComponent extends HTMLElement { /* … */ }
  return MyComponent;
})();

Step 4 — Replace barrel files with subpath exports

Aggregated index.ts barrel files force bundlers to parse the entire dependency graph before deciding which exports to include. A consumer importing one date utility ends up evaluating every re-export chain, including dynamic require() calls that halt static analysis entirely.

Replace a barrel re-export:

// src/index.ts  — BEFORE (barrel)
export { formatDate } from './utils/date';
export { parseAmount } from './utils/currency';
export { debounce } from './utils/function';
// … 40 more re-exports

With a subpath export pattern in package.json:

{
  "exports": {
    "./utils/date":     { "types": "./dist/types/utils/date.d.ts",     "import": "./dist/esm/utils/date.js" },
    "./utils/currency": { "types": "./dist/types/utils/currency.d.ts", "import": "./dist/esm/utils/currency.js" },
    "./utils/function": { "types": "./dist/types/utils/function.d.ts", "import": "./dist/esm/utils/function.js" }
  }
}

Consumer imports become:

import { formatDate } from '@org/ui-core/utils/date';
// Only date.js enters the consumer's module graph

Expected bundle diff (esbuild --bundle --analyze):

Before:  dist/bundle.js  284 kB  (entire library parsed)
After:   dist/bundle.js   18 kB  (only date.js included)

Step 5 — Enforce size budgets in CI

Manual size tracking degrades under delivery pressure. size-limit enforces thresholds on every pull request and fails the build when a commit regresses beyond the configured limit. Measure both gzip and brotli: gzip reflects what most caches store, brotli reflects what HTTP/2 servers deliver, and raw bytes reflect parse/compile cost independent of network.

[
  {
    "path": "dist/esm/index.js",
    "limit": "12 kB",
    "gzip": true,
    "brotli": true,
    "running": false
  },
  {
    "path": "dist/esm/utils/date.js",
    "limit": "2 kB",
    "gzip": true
  }
]
# .github/workflows/size-check.yml
name: Bundle Size Guardrails
on: [pull_request]
jobs:
  size-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npm run build
      - name: Enforce Size Budget
        run: npx size-limit --json > size-report.json
      - name: Fail on Threshold Violation
        run: |
          node -e "
            const r = require('./size-report.json');
            const over = r.filter(x => x.exceeded);
            if (over.length) {
              console.error('Size budget exceeded:', JSON.stringify(over, null, 2));
              process.exit(1);
            }
          "

Step 6 — Defer initialization and externalize shared dependencies

Static bundle metrics do not guarantee optimal runtime performance. A 50 kB library that runs synchronous DOM manipulation on module evaluation degrades Time to Interactive more than a 150 kB lazily loaded component. Defer initialization until first use:

let instance: ExpensiveThing | undefined;

export function getInstance(): ExpensiveThing {
  if (!instance) {
    instance = new ExpensiveThing();   // runs only on first call
  }
  return instance;
}

In monorepo and micro-frontend architectures, mark shared peer dependencies as externals in your build configuration rather than bundling them into each consumer’s output. Bundled duplicates increase memory usage and, for singleton-dependent libraries, trigger the dual-package hazard when multiple instances of a module end up in the same runtime.

// rollup.config.js
export default {
  input: 'src/index.ts',
  external: ['react', 'react-dom', 'lodash-es'],   // never bundled
  output: [
    { format: 'esm', dir: 'dist/esm', preserveModules: true },
    { format: 'cjs', dir: 'dist/cjs', exports: 'named' }
  ]
};

preserveModules: true in Rollup keeps the output as individual files (mirroring source structure) rather than one concatenated bundle, which gives downstream bundlers maximum granularity for dead-code elimination.


Bundle Resolution Flow

The following diagram shows how a bundler resolves a library import, and where each optimization in this guide intercepts the process to reduce output size.

Bundle resolution flow for frontend libraries A flowchart showing five stages: consumer imports symbol, bundler reads exports map, sideEffects check, pure annotation check, and final dead-code elimination output. import { foo } from '@org/lib' exports map resolves "import" → ESM path sideEffects declaration prunes modules No "import" condition → CJS fallback (no DCE) missing/wrong exports /*#__PURE__*/ annotations mark safe calls Dead-code elimination → minimal bundle size-limit CI gate Step 1: exports map + dual-format build Step 2: sideEffects declaration Step 3: PURE annotations

Hazard Call-Outs

HAZARD PREVENTION: Missing "sideEffects" field disables tree-shaking for the entire package

When "sideEffects" is absent from package.json, webpack and Rollup default to treating every module as having observable side effects. Even if you have zero CSS files and zero global mutations, omitting the field means the bundler cannot prune unused modules. Fix: add "sideEffects": false (pure libraries) or an explicit array.

HAZARD PREVENTION: "types" condition in wrong position overrides bundler resolution

If "types" appears after "import" or "require" in the exports map, TypeScript still resolves it correctly (it searches all conditions). But some bundlers with non-standard condition-name logic may resolve "types" at runtime and fail with a .d.ts file where a .js file was expected. Keep "types" first in every condition object.

HAZARD PREVENTION: preserveModules: false in Rollup collapses subpath exports

When Rollup concatenates all modules into a single output file, subpath import paths (@org/lib/utils/date) still work at runtime but the bundler receives one monolithic file and cannot prune individual subpaths. Use preserveModules: true for library builds that expose subpath exports.

HAZARD PREVENTION: "default" condition evaluated before "import" in some bundlers

esbuild evaluates conditions in the order the bundler specifies, not the order they appear in package.json. Always provide both "import" and "default" (with "default" as the last resort), and test with publint to catch condition-resolution issues before they reach consumers.


Tooling Validation

Run these commands before every release. They catch the configuration mistakes that break tree-shaking for consumers before the package reaches npm.

# 1. Check export-map validity and CommonJS/ESM interop
npx publint

# Expected (clean):
# ✓ No issues found

# 2. Check TypeScript type resolution against the exports map
npx attw --pack .

# Expected:
# ✓ "@org/ui-core" — no problems

# 3. Type-check the library itself
npx tsc --noEmit

# 4. Measure final output sizes
npx size-limit

Sample publint failure output (misconfigured "require" path):

✖  "exports['.']['require']" is './dist/cjs/index.js' but the file does not exist.
   Ensure the file exists or update the path.

Sample attw failure output (types resolving to wrong condition):

✖  "@org/ui-core" has types resolved to ESM (*.d.ts) but the 'require' condition
   resolves to CJS. Consider adding a separate .d.cts file.

Compatibility Matrix

Bundler / Tool "exports" map support "sideEffects": false /*#__PURE__*/ preserveModules output
webpack 5 Full (all conditions) Full Full Consumed correctly
webpack 4 Partial ("main" fallback) Full Full Consumed correctly
Rollup 3+ Full Full Full Native feature
Rollup 2 Partial Full Full Native feature
esbuild 0.17+ Full Full Full Consumed correctly
Vite 4+ (Rollup) Full Full Full Consumed correctly
Parcel 2 Full Partial (CSS only) Partial Consumed correctly
Node.js 12+ Full (ESM loader) N/A N/A N/A
Node.js 10 None ("main" only) N/A N/A N/A
TypeScript 4.7+ Full ("moduleResolution": "node16") N/A N/A N/A
TypeScript 5.0+ Full ("moduleResolution": "bundler") N/A N/A N/A

Webpack 4 does not read the "exports" field, so it always resolves to "main" (CJS). If you need to support webpack 4 consumers, keep "main" pointing to a CJS build and "module" pointing to ESM — webpack 4 + specific loaders will use "module".


Budgets That Fail the Build Instead of a Dashboard

Bundle size regresses the way a room gets untidy — never in one decision, always in twenty small ones. A budget only helps if it is enforced at the moment the decision is made, which means in the pull request that adds the byte, not in a monthly report.

Enforcing a size budget in the pull request A pull request triggers a build, which measures the gzipped size of each entry point against a committed baseline file. Within budget the check passes; over budget it fails with the delta and the modules responsible. The only budget that works is one that blocks a merge pull request one reviewable change build + measure gzip per entry point compare baseline committed, reviewable verdict pass or fail within budget baseline updated in the same PR growth is visible in the diff over budget fail with the delta and the modules raising the budget is a decision, not a default

The important design choice is that the baseline lives in the repository as a committed file rather than in a CI cache. A committed baseline means every size change appears in a diff, gets an explanation in the pull request description, and is reviewable by the same person reviewing the code. A cached baseline drifts invisibly and produces the classic outcome: a package that grew 40% over a year with no single commit responsible.

{
  "size-limit": [
    { "name": "root entry",  "path": "dist/index.mjs",   "limit": "12 kB", "gzip": true },
    { "name": "one symbol",  "path": "dist/index.mjs",   "limit": "3 kB",  "gzip": true, "import": "{ formatDate }" },
    { "name": "date module", "path": "dist/date.mjs",    "limit": "4 kB",  "gzip": true }
  ]
}

The second entry is the one that matters most for a library, because it measures what a consumer importing a single symbol actually pays — the number that reflects how well the package tree-shakes rather than how large it is in total.

Where the Bytes Usually Come From

Across libraries, four causes account for most unexpected size, and they are worth checking in order because the first two are usually one-line fixes.

A dependency that should have been external. If your build bundles a runtime dependency into dist/, every consumer gets your copy in addition to any copy they already had. Marking dependencies external is the default for library builds and easy to lose when a build config is rewritten. The check is a grep of the output for a string only that dependency would contain.

Duplicate helpers from down-levelling. Compiling to an older target injects helper functions — __awaiter, __spreadArray, class field polyfills — and depending on configuration these are inlined into every module that needs them. TypeScript’s importHelpers with tslib as a dependency replaces the copies with imports, at the cost of one small dependency. Raising the compilation target is the better fix where the supported runtimes allow it, since modern targets need no helpers at all.

Data tables that should be lazy. Locale data, unit conversion tables, emoji maps, timezone databases — these dominate size in libraries that have them, and they are almost always needed by a minority of consumers. Moving each table behind its own subpath export, or loading it with a dynamic import(), removes it from the default path entirely.

Source maps and comments shipped to production. A dist/ containing .map files is fine — consumers do not download them unless devtools are open — but an inline source map embedded in the JavaScript is shipped bytes. Similarly, a license banner preserved by the minifier on every module rather than once per bundle can add surprising weight to a package with many small files.

# what is actually in the published artefacts, biggest first
npm pack --dry-run --json | node -e '
  const files = JSON.parse(require("fs").readFileSync(0, "utf8"))[0].files;
  files.sort((a, b) => b.size - a.size).slice(0, 12)
    .forEach(f => console.log(String(f.size).padStart(8), f.path));
'
   41209 dist/locale-data.mjs
   18944 dist/index.mjs
   18102 dist/index.cjs
    6144 dist/index.d.ts
    2210 README.md

A single file dominating the tarball, as locale-data.mjs does above, is the clearest possible signal about where the next hour of optimisation work belongs — and, in this case, about which subpath export should exist so that consumers who never format a date in Icelandic stop paying for it.


Where the Weight Usually Sits

Before optimising anything, it helps to know the usual distribution — most libraries look roughly like this once measured.

Typical weight distribution in a published package Static data tables usually dominate, followed by feature code, then down-levelling helpers, then interop shims. The first category is almost always the one worth moving behind a subpath export. Measure before optimising — but this is the usual shape data tables locales, timezones, unit maps feature code the part consumers came for helpers injected by down-levelling interop shims appear when CJS enters the graph

The ordering matters for where to spend effort: moving one data table behind its own subpath usually beats a week of micro-optimising the feature code that consumers actually wanted.


Pages in This Section



← Tree-Shaking & Bundle Optimization