Optimizing Bundle Size for Frontend Libraries
Reduce frontend library bundle size via module resolution analysis, sideEffects configuration, conditional exports, and automated CI size-budget gating.
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.
Hazard Call-Outs
HAZARD PREVENTION: Missing
"sideEffects"field disables tree-shaking for the entire packageWhen
"sideEffects"is absent frompackage.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 resolutionIf
"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.tsfile where a.jsfile was expected. Keep"types"first in every condition object.
HAZARD PREVENTION:
preserveModules: falsein Rollup collapses subpath exportsWhen 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. UsepreserveModules: truefor library builds that expose subpath exports.
HAZARD PREVENTION:
"default"condition evaluated before"import"in some bundlersesbuild 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 withpublintto 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
- Measuring Bundle Impact with Bundlephobia and webpack Analyzer — quantify a library’s download and parse cost from the consumer’s perspective using Bundlephobia’s API and webpack’s visual bundle analyzer.
Related
- Implementing the sideEffects Flag Correctly — deep-dive on the
"sideEffects"field covering CSS asset handling, polyfill patterns, and per-bundler behavior differences. - Eliminating Barrel File Anti-Patterns — how aggregated re-export files defeat static analysis and the migration path to direct subpath imports.
- Advanced Dead-Code Elimination Techniques —
/*#__PURE__*/annotation strategies, class-field initialization ordering, and Rollup plugin hooks for custom DCE. - Mastering the package.json Exports Field — complete reference for condition keys, glob subpath patterns, and TypeScript declaration file pairing.
- Navigating the Dual-Package Hazard — what singleton bifurcation looks like at runtime and how correct export topology prevents it.