Reducing JavaScript Cost: Lazy Routes, @defer Triggers, injectAsync and Bundle Budgets
To reduce JavaScript cost, split startup code by route, defer invisible sections with @defer, load services needed on a click with injectAsync and protect the result with a bundle budget. In Bookstore startup code went from 383 KB to 359 KB and the admin report with Chart.js left the first load, but we also measured LCP getting 200-300 ms worse on lazy pages users land on directly.
Version note: The examples in this post were run with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports, and @angular/cdk 22.2.7), Chart.js 4.6.1, web-vitals 6.2.3, .NET 10 (ASP.NET Core 10) and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158.
Predict First
At the end of the previous post, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, downloaded 383.56 KB of JavaScript at startup. In the INP post we added the CDK's virtual scroll to the catalog page, and main.js grew from 316 KB to 376 KB. All pages are in the same route table, all of them in the initial load:
import { Routes } from '@angular/router';
import { BookPage } from './catalog/book-page';
import { CatalogPage } from './catalog/catalog-page';
import { HomePage } from './home/home-page';
export const routes: Routes = [
{ path: '', component: HomePage, title: 'Bookstore' },
{ path: 'books', component: CatalogPage, title: 'Catalog - Bookstore' },
{ path: 'books/:isbn', component: BookPage, title: 'Book - Bookstore' },
];
Predict: if we turn the catalog and book pages into lazy routes, how much smaller does the startup JavaScript get, and how does the LCP of these three pages change? Do they all improve?
The Cost of JavaScript per Byte
When an image arrives it is decoded and painted; a JavaScript file of the same size is parsed, compiled and executed after it arrives, all of it on the main thread. The size we see on the network is misleading too: according to the Angular CLI's estimate, the 383.56 KB of startup code is 107.39 KB on the network, but the browser parses 383 KB. In our phone profile (4x slower CPU, 1.6 Mbps) both costs are visible: on a CSR page the first content does not reach the screen before main.js has downloaded and run.
The usual order for shrinking startup code: first split at the route level with lazy routes, then defer the parts of a page that are not visible or needed later with @defer, then lazy-load services that only a click needs, and finally fix the imports that break tree-shaking. Protect each step with a bundle budget so that the next pull request does not bring it back. The build output of the starting point:
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-DUQPLOIU.js | - | 330.57 kB | 92.04 kB
main-YG5QL2FV.js | main | 51.37 kB | 14.93 kB
styles-CYMCJ46A.css | styles | 1.62 kB | 413 bytes
| Initial total | 383.56 kB | 107.39 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-HN53P3WQ.js | similar-books | 2.37 kB | 1.06 kB
Because of the @defer block on the book page, esbuild moved the shared code into an unnamed chunk; the total does not change. According to the source map, 23.6 KB of this code is @angular/cdk, used only by the catalog page.
Lazy Routes: One Chunk per Page
import { Routes } from '@angular/router';
import { HomePage } from './home/home-page';
export const routes: Routes = [
// The home page (LCP page, prerendered) stays in the initial bundle
{ path: '', component: HomePage, title: 'Bookstore' },
// Lazy routes: each page and what only it imports (the CDK for the catalog) get their own chunk
{ path: 'books', loadComponent: () => import('./catalog/catalog-page').then((m) => m.CatalogPage), title: 'Catalog - Bookstore' },
{ path: 'books/:isbn', loadComponent: () => import('./catalog/book-page').then((m) => m.BookPage), title: 'Book - Bookstore' },
];
loadComponent returns a dynamic import(); esbuild produces a separate chunk for each page, and the router downloads the chunk the first time the route is visited. The home page stays eager: it is our LCP page, it is prerendered, and most users enter there.
Initial chunk files | Names | Raw size | Estimated transfer size
main-DSCZI5NW.js | main | 356.05 kB | 98.87 kB
styles-CYMCJ46A.css | styles | 1.62 kB | 413 bytes
| Initial total | 357.67 kB | 99.28 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-DGXVJ7Zf.js | catalog-page | 28.54 kB | 7.79 kB
chunk-CQeLNMuD.js | book-page | 2.67 kB | 1.13 kB
chunk-CALZCXOb.js | similar-books | 2.43 kB | 1.11 kB
Startup code went from 383.56 KB to 357.67 KB, and from 107.39 KB to 99.28 KB on the network. The catalog chunk is 28.54 KB and took the CDK with it. But not all of the 60 KB that the CDK brought in post 05 came back. When we built the same structure with a catalog page without the CDK, main.js was 327.09 KB; so even when lazy, the CDK leaves 29 KB in the startup code. According to the source map, 20.4 KB of the difference is @angular/core and 8.2 KB is rxjs code: framework code the CDK uses and that esbuild kept in the shared startup chunk in this build. Moving a library into a lazy chunk does not mean moving all of its cost; you cannot know without measuring the build output.
The three pages measured with the phone profile (median of 5 runs):
| Version | Startup JS (KB, raw / network) | / LCP | /books FCP / LCP | /books/:isbn FCP / LCP |
|---|---|---|---|---|
| A: all pages eager | 383.56 / 107.39 | 1384 | 1352 / 1352 | 1336 / 1904 |
| B: catalog and book page lazy | 357.67 / 99.28 | 1336 | 1248 / 1556 | 1248 / 2188 |
E: B + lazy admin, @defer, injectAsync | 359.10 / 103.05 | 1448 | 1264 / 1572 | 1256 / 2220 |
FCP dropped by about 100 ms on both lazy pages, and the catalog FCP we lost in post 05 came back. But LCP got worse on both pages: from 1352 to 1556 ms on the catalog and from 1904 to 2188 ms on the book page. The reason is in the waterfall:
resource start end render-blocking
/books 0 156 non-blocking
/main-DSCZI5NW.js 164 1169 non-blocking
/styles-CYMCJ46A.css 166 321 non-blocking
/fonts/inter-latin-400-normal.woff2 176 569 non-blocking
/chunk-DGXVJ7Zf.js 1227 1492 non-blocking
/fonts/inter-latin-700-normal.woff2 1228 1567 non-blocking
/api/books 1530 2865 non-blocking
analytics.thirdparty.test:5302/analytics.js 1569 2579 non-blocking
analytics.thirdparty.test:5302/collect 2644 2901 non-blocking
FCP 1236 ms, LCP 1544 ms
These pages are rendered in the browser, and the HTML the server sends does not know which route will open. The browser first downloads and runs main.js (1169 ms), only then does the router match the route and request the catalog chunk (1227-1492 ms), and the API request starts after the chunk (1530 ms). A lazy route means one more round trip for a user who lands directly on that page. For users coming from another page inside the app, router preloading can hide this cost; for direct landings the fix is for the HTML to know the route up front, which means SSR. We will measure that in the next post. Decide which pages should be lazy by looking at the landing page distribution in RUM: if half of your users enter on a book page, making that page lazy is expensive.
A Chart Library and a Bundle Budget
We add a performance report page to Bookstore's back office: the p75 table from RUM and a bar chart for LCP. For the chart we used Chart.js 4.6.1. We first added the page as an eager route and, at the same time, tightened the budget in angular.json to slightly above the 357.67 KB of version B:
"budgets": [
{
"type": "initial",
"maximumWarning": "380kB",
"maximumError": "420kB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
$ npx ng build
Initial chunk files | Names | Raw size | Estimated transfer size
main-TSVJQRMV.js | main | 367.62 kB | 104.48 kB
chunk-DIj1RMPJ.js | - | 198.27 kB | 58.20 kB
styles-CYMCJ46A.css | styles | 1.62 kB | 413 bytes
| Initial total | 567.51 kB | 163.09 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-jQWBPDkk.js | catalog-page | 28.47 kB | 7.81 kB
chunk-DdRzux1R.js | book-page | 2.71 kB | 1.15 kB
chunk-3LYvsyG5.js | similar-books | 2.47 kB | 1.14 kB
chunk-CAh5tica.js | report-exporter | 502 bytes | 502 bytes
Application bundle generation failed.
▲ [WARNING] bundle initial exceeded maximum budget. Budget 380.00 kB was not met by 187.51 kB with a total of 567.51 kB.
✘ [ERROR] bundle initial exceeded maximum budget. Budget 420.00 kB was not met by 147.51 kB with a total of 567.51 kB.
A page used by a few staff members added a 198 KB Chart.js chunk to every customer's first load, and the build broke. That is the job of a budget: catching the regression in the build instead of hoping someone notices it in code review. maximumWarning stays in the CI logs and is often not read; maximumError stops the pipeline. Put the limit slightly above today's size and change it in the pull request when growth is intentional. Other budget types such as anyComponentStyle limit individual chunks and component styles. Its lab counterpart is metric thresholds in Lighthouse CI (for example an assert for LCP and TBT); the setup is in post 06 of the GitHub Actions series, and we did not run it in this post.
@defer Triggers and injectAsync
Making the admin route lazy took Chart.js out of the startup code. Inside the page we also load the chart and the CSV export separately:
<!-- chart.js is downloaded when the chart area becomes visible, and prefetched when the browser is idle -->
@defer (on viewport; prefetch on idle) {
<app-p75-chart [rows]="rows.value() ?? []" />
} @placeholder {
<div class="chart-slot"></div>
}
export class ReportsPage {
protected readonly rows = httpResource<RumRow[]>(() => '/api/rum/p75?minSamples=1');
// The exporter's code is downloaded when the browser is idle, the service is created on first use
private readonly exporter = injectAsync(() => import('./report-exporter').then((m) => m.ReportExporter), { prefetch: onIdle });
protected async exportCsv(): Promise<void> {
(await this.exporter()).download(this.rows.value() ?? []);
}
@defer's triggers decide when the content loads: on idle (the default, requestIdleCallback), on viewport (when the placeholder becomes visible), on interaction (a click or key press), on hover, on immediate, on timer(2s), and a condition with when. prefetch on ... separates downloading the code from rendering: the chunk downloads while the browser is idle and the content renders as soon as it enters the viewport. The placeholder has the same height as the chart, for the reason we saw in the CLS calculation of the previous post.
injectAsync is a new API in Angular 22: it is marked @publicApi 22.0 in @angular/core's typings. It takes a service through a dynamic import and returns a function; when the function is called, the chunk downloads and the service instance is created. The service must be providedIn: 'root'. With prefetch: onIdle the code is downloaded in an idle moment before the click, so there is no network wait at click time. The JavaScript files downloaded with the phone profile when we open the page and wait, scroll, and click "Export CSV":
559 ms /chunk-BGXGIut6.js 11.6 kB
1032 ms /main-XBNY7HR4.js 50.2 kB
1203 ms /chunk-BuwLbaQP.js 84.4 kB
1411 ms /chunk-DMaf4PNH.js 1.2 kB
1608 ms /chunk-BSzDn4ez.js 0.4 kB
2125 ms /chunk-CkzlGH2p.js 70.7 kB
2343 ms /analytics.js 76.1 kB
3168 ms -- load finished, waiting 3 s (browser idle)
6169 ms -- scroll 400 px
7688 ms -- scroll 400 px
9190 ms -- click text=Export CSV
The first three files are startup code, and chunk-DMaf4PNH.js is the report page. The exporter (0.4 KB) and the chart (70.7 KB, compressed size on the network) download right after the page opens: the exporter while idle, and the chart because its placeholder is already visible on the phone screen. Nothing downloads at scroll or click time. If the table were longer and the chart sat below the screen, on viewport alone would wait for the scroll; that is where prefetch on idle helps.
Imports That Break Tree-Shaking
The shortest way to use Chart.js is import { Chart } from 'chart.js/auto'. This entry point registers every chart type, scale and plugin; it is an import with side effects, and esbuild cannot drop the unused chart types. When we register only what the bar chart needs:
import { Component, ElementRef, effect, input, viewChild } from '@angular/core';
import { BarController, BarElement, CategoryScale, Chart, Legend, LinearScale, Tooltip } from 'chart.js';
import { RumRow } from './rum-row';
// 'chart.js/auto' registers every chart type, scale and plugin; register only what this bar chart uses
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend);
The p75-chart chunk went from 206.88 KB to 161.00 KB, and from 61.71 KB to 49.18 KB on the network. The same kind of problem shows up elsewhere. A CommonJS dependency does not take part in tree-shaking, and the Angular CLI prints a warning for it during the build; finding a package with an ESM build is better than silencing the warning with allowedCommonJsDependencies. Barrel files (index.ts) that re-export everything can pull all of their modules into the bundle if one of them has side effects. For third-party embeds (a video player, a map) the facade approach works: show a static preview image first and load the real embed when the user clicks. The tools for inspecting the inside of a bundle in detail (esbuild metafile, source-map-explorer) belong to the upcoming Build Tools series.
Production Notes
The home page's LCP is 110 ms worse in E than in B (1336 vs 1448 ms; two separate 5-run measurements gave 1452 and 1448). The total startup code is almost the same (357.67 vs 359.10 KB), but its structure changed: when the new lazy chunks were added, esbuild split the startup code into three files and Angular put two of them into <head> as modulepreload. In B there was a single main.js. On a 1.6 Mbps connection these files share the bandwidth with the hero image from the start. Measuring total bytes is not enough; measure LCP too, because a change can alter the chunk structure of another page.
A budget on initial alone does not protect the lazy chunks; the catalog chunk can grow too. For important lazy pages, consider a separate budget of type bundle with a name ("name": "catalog-page"). In RUM, also track LCP and FCP per route: direct-landing pages pay the price of the lazy route decision, and in the lab you only see it if you measure that page on its own.
Common Pitfalls
We also measured the most expensive pitfall: we wrapped the home page's hero in @defer (on idle) and gave the placeholder aspect-ratio: 2 / 3.
http://127.0.0.1:5300/ profile=mobile runs=5
run TTFB FCP LCP CLS
1 151 320 2276 0.0737
2 151 292 2264 0.0737
3 151 296 2264 0.0737
4 150 292 2248 0.0737
5 151 292 2264 0.0737
med 151 292 2264 0.0737
LCP subparts (median): ttfb=151 loadDelay=1541 loadDuration=552 renderDelay=16
LCP element: html>body>app-root>main>app-home-page>section.hero>img.hero-cover
LCP went from 1448 to 2264 ms. Although the home page is prerendered, @defer renders only the placeholder on the server; the hero image enters the DOM only after the JavaScript has downloaded and the browser is idle (load delay 1541 ms). Because the placeholder is not exactly the size of the image, CLS became 0.0737 as well.
- Deferring the LCP content or a section visible on the first screen with
@defer. - Making a direct-landing CSR page lazy without looking at LCP; it adds a round trip.
- Assuming that moving a library into a lazy chunk removes all of its cost. 29 KB of the CDK stayed in the startup code.
- Entry points that register everything, such as
chart.js/auto, and barrel imports with side effects. - Setting the budget so tight that it is always raised or so loose that it never fires; both get ignored soon.
- Looking only at total bytes; the number of files and the loading order also change LCP.
A Small Experiment
★☆☆ Add { "type": "bundle", "name": "catalog-page", "maximumError": "35kB" } to angular.json for the catalog chunk and break the build by importing another CDK module into the catalog page.
★★☆ Make the table on the report page longer (for example list every metric twice instead of minSamples=1) so that the chart sits below the screen. With chunks.mjs, compare when the chart chunk downloads in two versions, with and without prefetch on idle.
★★★ Win back the catalog page's LCP loss: write a small directive that downloads the catalog chunk in advance when the user hovers over the "Catalog" link on the home page (a dynamic import() on mouseenter), and measure the time to the first render after the click when moving from the home page to the catalog.
Startup code went from 383 KB to 359 KB, and a 160 KB chart library stayed out of every customer's first load, but LCP got worse on lazy pages that users land on directly. In the next post we measure the book detail page with CSR, SSR and prerendering: SSR's cost in TTFB, incremental hydration's hydrate on viewport trigger, event replay, and caching the SSR output.