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".
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.
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.
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
-
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.
-
Code-Splitting Entry Points for Libraries — choosing boundaries consumers can address, and avoiding shared-chunk duplication.
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.