Angular Bundle Analysis and Tree-Shaking: Ship Less JavaScript [2026]
Every kilobyte of JavaScript in your initial bundle has to be downloaded, parsed and executed before the app can respond to the user. On a fast laptop that's invisible; on a mid-range phone over a mobile connection, a bloated bundle is the difference between an app that feels instant and one that feels broken. The good news is that most bundle bloat comes from a handful of predictable causes, and the Angular build tells you exactly where it is if you know how to ask.
This is lesson 8.5 of the Angular Tutorial. Lesson 8.4 made images load efficiently; this one does the same for your JavaScript. You'll read the build output, generate a bundle map, find what's large and why, fix tree-shaking blockers, move code out of the initial bundle with lazy routes and @defer, and set budgets so the bundle doesn't quietly grow back.
Read the build output first #
Run a production build:
ng build
The Angular CLI's application builder (esbuild and Vite, the default since Angular 17) prints two tables:
| Table | What it contains | What matters |
|---|---|---|
| Initial chunk files | Everything needed before the first render: main, polyfills, styles, shared chunks |
This is what the user waits for. Keep it small |
| Lazy chunk files | Code split off by lazy routes, @defer blocks and dynamic import() |
Loaded on demand; size matters less, as long as each chunk is reasonable |
Each row shows a raw size and an estimated transfer size (compressed). Raw size is roughly what the browser has to parse and execute; transfer size is what goes over the network. Watch both, but the initial total is your headline number.
Generate a bundle map #
The tables tell you how big, not why. For that, ask the build for its dependency graph:
ng build --stats-json
With the esbuild-based builder this writes stats.json, an esbuild metafile, into your output folder. Drop it into the esbuild bundle analyzer (esbuild.github.io/analyze) to get a treemap of every module in every chunk, with sizes. Webpack-era tools like webpack-bundle-analyzer don't understand this format; use the esbuild analyzer instead.
A second, builder-independent view comes from source maps:
ng build --source-map
npx source-map-explorer "dist/my-app/browser/*.js"
source-map-explorer attributes every byte of the output back to the source file it came from, which is useful for answering "how much of main.js is really my code?"
The usual suspects #
Open the treemap and look at the biggest rectangles in the initial chunks. In most Angular apps, the culprits are some mix of these:
| Culprit | How it shows up | Fix |
|---|---|---|
| A heavy library imported eagerly (charts, editors, PDF, maps) | One huge block in main |
Load it lazily: @defer or a lazy route |
| Whole-library imports | All of lodash, all icons, all locales |
Import only what you use; prefer ESM packages (lodash-es, per-icon imports) |
| CommonJS dependencies | Build warnings about modules that aren't ESM | Replace with an ESM alternative, or accept it knowingly |
| Date / i18n libraries with every locale | Hundreds of KB of locale data | Use Intl APIs or import specific locales |
| Polyfills you no longer need | Large polyfills chunk |
Remove old polyfills; modern browsers don't need them, and zoneless apps drop zone.js |
| Feature code in the initial bundle | Admin screens, settings, rarely used dialogs inside main |
Lazy routes (loadComponent, loadChildren) |
Make tree-shaking work for you #
Tree-shaking removes code that's imported but never used. esbuild does it well, but only when the code allows it:
- ES modules only. Tree-shaking relies on static
import/export. CommonJS modules (require,module.exports) can't be analysed, so the whole module ships. The build warns about CommonJS dependencies; treat those warnings as a to-do list, not noise. Silence one (viaallowedCommonJsDependencies) only after deciding it's acceptable. - Import precisely.
import { debounce } from 'lodash-es'ships one function;import _ from 'lodash'ships the whole library. - Tree-shakable providers. Services declared with
providedIn: 'root'are only bundled if something injects them. Registering a service in aprovidersarray bundles it even if nothing uses it. - Avoid side effects at module level. Code that runs when a module is imported (registering globals, patching prototypes) forces the bundler to keep that module. Keep module top levels to declarations.
- Beware re-exporting barrels in libraries you control: a barrel file that also contains side effects can pull everything it re-exports into the bundle.
Move code out of the initial bundle #
Removing code is best; deferring code you still need is next best.
Lazy routes split whole features off:
// app.routes.ts
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'reports', loadComponent: () => import('./reports/reports').then(m => m.Reports) },
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES) },
];
@defer splits off parts of a page, which is perfect for heavy widgets below the fold:
<app-order-summary [order]="order()" />
@defer (on viewport) {
<app-sales-chart [data]="history()" /> <!-- chart library loads only when scrolled into view -->
} @placeholder {
<div class="chart-skeleton"></div>
}
Everything referenced only inside the @defer block, including the chart component and the charting library it imports, moves into a lazy chunk. Lesson 5.6 covers the triggers (on viewport, on idle, on interaction, prefetch) in depth.
Dynamic import() handles the rest, such as a library needed only after a button click:
async exportPdf() {
const { jsPDF } = await import('jspdf'); // downloaded on first use
new jsPDF().text('Report', 10, 10).save('report.pdf');
}
Set budgets so it stays fixed #
Bundle size creeps back one dependency at a time. Budgets in angular.json turn that creep into a build warning or error:
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB", "maximumError": "8kB" }
]
These are the values new projects start with. Once you've slimmed your bundle, tighten the initial budget to just above your current size, so the next heavy import fails the build and gets a conversation instead of slipping into production. Budgets also accept types like anyScript and bundle (with a name) for finer control.
A repeatable workflow #
ng buildand note the initial raw and transfer totals.ng build --stats-jsonand open the treemap; list the five biggest items in the initial chunks.- For each: remove it, replace it with something smaller, import it precisely, or defer it.
- Rebuild and compare the totals. Keep changes that measurably help.
- Tighten the
initialbudget to lock in the gain.
Do this per release, or whenever a dependency is added. It takes minutes, and it's much easier than untangling a year of growth.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
webpack-bundle-analyzer can't read stats.json |
The esbuild builder writes an esbuild metafile | Use the esbuild analyzer, or source-map-explorer |
Library still in main after adding @defer |
It's also imported eagerly somewhere else | Search for other imports; everything must go through the deferred path |
| CommonJS warning for a dependency | Package isn't published as ES modules | Find an ESM alternative, or accept it deliberately |
| Budget error after a small change | A new import pulled in a large dependency | Check the treemap diff; defer or replace it |
| Lazy chunk is huge | One feature imports too much, or a shared library lands in it | Split the feature further; check what the chunk contains |
| Build is small but the app is slow to start | Heavy work runs at startup, not bundle size | Profile startup (lesson 8.6); defer initialisation work |
What's next #
Lesson 8.6 brings images, JavaScript and rendering together under Core Web Vitals: measuring LCP, INP and CLS for a single-page app, the difference between lab data (Lighthouse) and field data (CrUX), and Angular-specific fixes for each metric.
Try it yourself #
Run ng build --stats-json on a real project, open the treemap, and write down the five largest modules in the initial chunks. Move one heavy, below-the-fold component into a @defer (on viewport) block, rebuild, and compare the initial totals. Then set your initial budget to 10% above the new size.
run_targetI ran the production build with --stats-json and checked the metafile. Three items make up about 900 KB of the initial chunks: (1) echarts (~420 KB), imported eagerly by DashboardChart on the home page. Wrap that component in @defer (on viewport). (2) moment with all locales (~290 KB), used in 4 places for date formatting. DatePipe or Intl.DateTimeFormat can replace it. (3) The admin feature (~190 KB) is routed with component: instead of loadChildren. Make it a lazy route. Those three changes should bring the initial bundle under the 1 MB error budget, and deferring the chart alone gets you most of the way. The build also warns that xlsx is a CommonJS dependency, but it’s only used in a lazy route, so it doesn’t affect initial load.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


