Bundle Size Optimization
Every kilobyte your app ships is time the browser spends downloading, parsing, and executing JavaScript before a user sees anything useful. Bundle size optimization is the discipline of measuring exactly what ends up in your production output and trimming the parts that don’t earn their place. Modern Angular gives you three complementary tools for this: a tree-shaking build pipeline, budgets that fail the build when bundles grow too large, and source-map analysis to see where the bytes actually went.
Measuring the production bundle
You can only optimize what you can see. Start by producing a real production build, which enables minification, tree-shaking, and ahead-of-time (AOT) compilation:
ng build --configuration production
Output:
Initial chunk files | Names | Raw size | Transfer size
main-7QK2A4ZP.js | main | 412.18 kB | 112.04 kB
polyfills-FELXNDLF.js | polyfills | 34.58 kB | 11.32 kB
styles-5INURTSO.css | styles | 8.21 kB | 2.14 kB
| Initial total | 454.97 kB | 125.50 kB
Application bundle generation complete. [6.812 seconds]
The Transfer size column reflects gzip/brotli compression and is what your users actually download. Watch that number, not the raw size.
Analyzing what’s inside with source-map-explorer
The build summary tells you a chunk is large, but not why. source-map-explorer reads the generated source maps and attributes every byte back to the module it came from, rendering an interactive treemap.
ng build --configuration production --source-map
npx source-map-explorer dist/my-app/browser/main-*.js
This opens a treemap in your browser where each rectangle’s area is proportional to its byte contribution. Common surprises include a moment.js locale bundle, an entire icon library imported for two icons, or a utility package pulled in transitively. Once you can see the offender, the fix is usually a more specific import or a lighter dependency.
Source maps must be enabled for analysis (
--source-map), but never deploy them to production — they expose your original source. Generate them into a throwaway build instead.
The newer esbuild-based builder also ships a native stats output. Add "statsJson": true under the build options and inspect dist/.../stats.json with tools like webpack-bundle-analyzer for an alternative view.
Enforcing limits with budgets
Budgets turn bundle size into a CI gate. Instead of discovering a regression weeks later, the build warns or errors the moment a threshold is crossed. They live in angular.json under each project’s production build configuration:
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
},
{
"type": "bundle",
"name": "lazy-reports",
"maximumWarning": "150kB"
}
]
When a bundle exceeds an error threshold, the build fails:
Output:
▲ [WARNING] bundle initial exceeded maximum budget.
Budget 500.00 kB was not met by 12.34 kB with a total of 512.34 kB.
Error: bundle initial exceeded maximum budget.
Budget 1.00 MB was exceeded by 24.10 kB.
Budget types
type | What it measures |
|---|---|
initial | The bundles loaded on first paint (eager chunks) |
all | Every bundle and asset combined |
bundle | A specific named bundle (pair with name) |
anyComponentStyle | Each individual component’s styles |
any | Any single bundle or asset file |
Sizes accept kB/mb suffixes or percentage strings like "10%" relative to a baseline, which is handy for catching gradual creep.
Writing tree-shakable code
Tree-shaking only removes code the bundler can prove is unused. Several modern Angular patterns make your own code maximally shakable.
Use providedIn: 'root' for services. A root-provided service is only included in the bundle if something actually injects it. Services registered eagerly in a module’s providers array are always kept.
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class TelemetryService {
track(event: string): void {
navigator.sendBeacon('/t', event);
}
}
Prefer standalone components and inject(). Standalone components import only the directives and pipes they use, so unused framework features never reach the bundle. The functional style also avoids dragging in decorator metadata you don’t need:
import { Component, inject, signal } from '@angular/core';
import { TelemetryService } from './telemetry.service';
@Component({
selector: 'app-cart',
standalone: true,
template: `
@if (items().length) {
<ul>
@for (item of items(); track item.id) {
<li>{{ item.name }}</li>
}
</ul>
} @else {
<p>Your cart is empty.</p>
}
`,
})
export class CartComponent {
private telemetry = inject(TelemetryService);
items = signal<{ id: number; name: string }[]>([]);
}
Import narrowly from libraries. Deep, named imports let the bundler drop everything you didn’t reference. Avoid barrel imports of huge namespaces:
// Good — only `debounce` is bundled
import debounce from 'lodash-es/debounce';
// Bad — risks pulling in the whole library
import _ from 'lodash';
Note lodash-es (ESM) is tree-shakable while the CommonJS lodash often is not. The build will warn you with a “CommonJS or AMD dependencies can cause optimization bailouts” message — treat those warnings as a checklist.
Best Practices
- Run
ng build --configuration productionand read the transfer size before and after every dependency you add. - Add
initialandanyComponentStylebudgets toangular.jsonand wire the build into CI so regressions fail fast. - Profile large chunks with
source-map-explorerrather than guessing — the byte hog is rarely what you expect. - Prefer ESM (
*-es) builds of third-party libraries and import individual functions, not whole namespaces. - Use
providedIn: 'root', standalone components, andinject()so unused code is genuinely eliminated. - Move rarely-used features into lazy-loaded routes or
@deferblocks so they leave the initial bundle entirely. - Resolve every “CommonJS dependency” and “optimization bailout” warning the build emits.