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".


Pages in This Section



← Tree-Shaking & Bundle Optimization