Mert Özen Further With Every Line
Frontend Build Tools — #05

The Angular CLI Build System: the application Builder, esbuild + Vite, the Move to Rolldown and Migrating from webpack

Mert Özen Nov 14, 2026 15 min 18 views
The Angular CLI Build System: the application Builder, esbuild + Vite, the Move to Rolldown and Migrating from webpack

The Angular CLI's application builder bundles the app with esbuild, reorganizes the chunks with Rolldown once the number of lazy chunks passes a threshold, and uses Vite only as the dev server. Migrating Bookstore's Angular app from the webpack-based browser builder, we look at the output moving into a browser folder, a main bundle cut in half because of moment's locale files, the limits of HMR, the define and loader options, the Baseline browser target and the TypeScript 6.0 requirement.

Version note: The examples in this post were run with Angular 22.2 (Angular CLI and @angular/build 22.2.7, which bundle esbuild 0.28, Vite 8.3 and Rolldown 1.2), TypeScript 6.0 and Node.js 24; Angular 22.2 does not support TypeScript 7.0, so the Angular project uses TypeScript 6.0. Browser behavior was observed with Playwright 1.65's Chromium. Timings come from the author's own machine; look at the ratios, not the absolute values.

Guess First

bookstore-web is the Angular client of Bookstore, the online bookstore used as the shared example across this site's series. It was set up a few years ago and is still built with the webpack-based browser builder. The catalog page uses moment for dates and lodash for grouping; the book detail, cart, my orders and admin pages are lazy routes. For years the deploy script has done this:

npx ng build
Copy-Item -Recurse dist/bookstore-web/* \\web01\bookstore\

After upgrading to Angular 22 we run the migration the CLI suggests, and the builder becomes @angular/build:application. Not a single line of source code changes. Two questions:

  • Does the deploy script keep working as it is?
  • What happens to the size of the main bundle: does it stay the same, grow or shrink?

This is the fifth post of the Build Tools series. In the previous four posts we looked at webpack, esbuild and Vite one by one; now we look at how the Angular CLI puts these tools together: the inside of the application builder, Rolldown reworking esbuild's output, Vite used only as a dev server, migrating from webpack and the browser target. We covered the announcement of the application builder in Angular 17 in the Angular series post "What's New in Angular 17: @if, @for, @defer and the New Application Builder", and budgets and environment files in "Angular Build and Deploy"; we do not repeat them here.

Inside the application Builder: Who Does What?

When you create a new project, the build target in angular.json already uses the new builder:

npx @angular/cli@22.2 new bookstore-web --directory web --style=css --ssr=false --ai-config=none
"build": {
  "builder": "@angular/build:application",
  "options": {
    "browser": "src/main.ts",
    "tsConfig": "tsconfig.app.json",
    "assets": [{ "glob": "**/*", "input": "public" }],
    "styles": ["src/styles.css"]
  }
}

The package.json of the @angular/build package shows plainly which parts the builder is made of: esbuild 0.28.2, Vite 8.3.5, Rolldown 1.2.16, oxc-parser, sass and Babel. During an ng build the work is split like this:

  • The Angular compiler (ngtsc) runs as an esbuild plugin: it compiles templates, type checks and turns TypeScript into JavaScript. The type check is the counterpart of the tsc --noEmit step we ran separately in the esbuild post of this series; here the builder does it itself.
  • esbuild bundles: the module graph, tree shaking, code splitting for lazy routes and minification.
  • Rolldown reorganizes the chunks esbuild produced in a second pass (next section).
  • Oxc replaces Babel in Angular-specific transformations: in 22.1 the advanced optimization transforms, and in 22.2 the Angular linker (the step that finalizes precompiled Angular libraries) moved to oxc-parser.
  • Vite only comes into play during ng serve.

The production build:

npx ng build
Initial chunk files | Names            |  Raw size | Estimated transfer size
main-MB2FDNUA.js    | main             | 358.68 kB |               101.98 kB
styles-WBKGN4YZ.css | styles           |  78 bytes |                78 bytes

                    | Initial total    | 358.75 kB |               102.06 kB

Lazy chunk files    | Names            |  Raw size | Estimated transfer size
chunk-DgJXKc3X.js   | book-detail-page |   1.04 kB |               596 bytes
chunk-q36WD529.js   | orders-page      | 759 bytes |               759 bytes
chunk-D5_26l1o.js   | cart-page        | 728 bytes |               728 bytes
chunk-CTpOvIBR.js   | admin-page       | 669 bytes |               669 bytes

Application bundle generation complete. [1.413 seconds]

▲ [WARNING] Module 'lodash' used by 'src/app/admin/admin-page.ts' is not ESM
▲ [WARNING] Module 'moment' used by 'src/app/orders/orders-page.ts' is not ESM

We set the CommonJS warnings aside for now; finding what bloats the bundle is the topic of the next post in the series. Look at the file names: main-MB2FDNUA.js uses esbuild's upper-case hash format, while the lazy chunks look like chunk-D5_26l1o.js, with mixed case. The second format is Rolldown's, and it is the first sign that someone touched esbuild's output afterwards.

Chunk Optimization: Why Does Rolldown Re-bundle esbuild's Chunks?

esbuild's code splitting is correct but fine-grained: every group of modules shared by two entries (here main and the lazy routes) goes into its own chunk. In a real application that means many small files the browser has to download on first load. That is why Angular 22.0 made a "chunk optimization" step that runs after esbuild the default; in 22.1 the tool doing this step moved from Rollup to Rolldown. We can see what the step does by switching it off and on. On Windows with PowerShell:

$env:NG_BUILD_OPTIMIZE_CHUNKS = "false"
npx ng build
Remove-Item Env:NG_BUILD_OPTIMIZE_CHUNKS
Initial chunk files | Names            |  Raw size | Estimated transfer size
chunk-BV4774W7.js   | -                | 121.89 kB |                36.73 kB
chunk-QJSI765M.js   | -                |  95.14 kB |                23.99 kB
chunk-ONXP5GQA.js   | -                |  72.81 kB |                23.43 kB
chunk-CTHDJKAH.js   | -                |  62.92 kB |                18.34 kB
main-MB2FDNUA.js    | main             |   3.07 kB |                 1.25 kB
chunk-ZUTRIRL5.js   | -                | 918 bytes |               918 bytes
chunk-VRM7G5WT.js   | -                | 542 bytes |               542 bytes
chunk-DKPVJ5LR.js   | -                | 120 bytes |               120 bytes
styles-WBKGN4YZ.css | styles           |  78 bytes |                78 bytes

                    | Initial total    | 357.49 kB |               105.39 kB

esbuild's own output has 8 files for the first load; main is only 3 kB and imports the shared chunks for the rest. With optimization on, the same code ends up in a single main file and the estimated transfer size drops from 105.39 kB to 102.06 kB, because one large file compresses better than many small ones. What matters more to the browser is the request count: 1 JavaScript request instead of 8.

The logic in the @angular/build source is simple. Optimization runs only when script optimization is on and the number of lazy chunks reaches a threshold; the threshold is 3 by default and can be changed with NG_BUILD_OPTIMIZE_CHUNKS (false disables it, true always runs it, a number sets the threshold). Rolldown takes the chunks esbuild produced as input, re-bundles them starting from main and does not minify again, because esbuild already did. Our project has four lazy routes, so the threshold is reached. In a measurement with NG_BUILD_DEBUG_PERF=1, this step took about 6 percent of the total build time (DURATION[OPTIMIZE_CHUNKS]: 0.054410625s).

If the move to Rolldown causes trouble, NG_BUILD_CHUNKS_ROLLDOWN=false goes back to the old Rollup path; but Rollup is no longer a dependency of @angular/build, and the build stops with a clear error:

✘ [ERROR] Chunk optimization failed

  Rollup is required when 'NG_BUILD_CHUNKS_ROLLDOWN' is set to false. Please install 'rollup' manually (e.g. 'npm install rollup --save-dev') to use this fallback.

ng serve: Here Vite Is Only the Dev Server

In the Vite post of this series we saw that Vite does not bundle source code in dev mode and transforms each file on request. Angular's dev server works differently. ng serve compiles and bundles the application code in memory with esbuild and hands the output to Vite; Vite is used only as the server, the HMR channel and for dependency pre-bundling. The Angular docs also state plainly that Vite cannot be configured directly. The requests made when we opened the app in Chromium (paths shortened, port written as the default 4200):

http://localhost:4200/@vite/client
http://localhost:4200/styles.css
http://localhost:4200/main.js
http://localhost:4200/chunk-SB5DMEEX.js
http://localhost:4200/chunk-VSGMQDDN.js
http://localhost:4200/@fs/.../web/.angular/cache/22.2.7/bookstore-web/vite/deps/@angular_core.js?v=3f8e8d8d
http://localhost:4200/@fs/.../web/.angular/cache/22.2.7/bookstore-web/vite/deps/lodash.js?v=3f8e8d8d
http://localhost:4200/@fs/.../web/.angular/cache/22.2.7/bookstore-web/vite/deps/moment.js?v=3f8e8d8d
http://localhost:4200/@fs/.../web/.angular/cache/22.2.7/bookstore-web/vite/deps/rolldown-runtime-BcuH8Uup.js?v=f35d19b7
http://localhost:4200/@ng/component?c=src%2Fapp%2Fcatalog%2Fcatalog-page.ts%40CatalogPage&t=...
http://localhost:4200/@ng/component?c=src%2Fapp%2Fapp.ts%40App&t=...

Source files do not arrive one by one; main.js and the chunks are esbuild's dev build. The Angular packages, lodash and moment are files Vite pre-bundled: they live under .angular/cache, and rolldown-runtime in the file name shows that Vite 8 did this with Rolldown. moment and lodash, which are CommonJS, are converted to ESM in this step, which is why they run in the browser. You control pre-bundling with the prebundle option of the dev-server builder: you can leave a problematic package out with prebundle.exclude.

The @ng/component requests are part of HMR. Angular's HMR covers component templates and styles, not general JavaScript changes. We kept the page open with Playwright, added Emma to the cart, typed "austen" into the search box and made three changes:

start -> loads: 1 | cart: Cart (1) | nav1: My orders | input: "austen"
template change -> loads: 1 | cart: Cart (1) | nav1: Orders | input: ""
component style change -> loads: 1 | cart: Cart (1) | nav1: Orders | header border: rgb(245, 158, 11)
ts change -> loads: 2 | cart: Cart (0) | nav1: Orders

The ng serve terminal printed Component update sent to client(s). for the first two and Page reload sent to client(s). for the third. With the app.html and app.css changes the page did not reload and the cart count was kept, because the cart lives in a root service (CartStore). But the search box was emptied: HMR recreated the view of the App component, and the catalog page inside router-outlet was recreated with it. So HMR does not keep the own state of the changed component and the components below it; state in services survives. When a plain TypeScript file such as format.ts changes, there is a full reload and the cart is reset. You can turn template HMR off with ng serve --no-hmr.

Migrating from the browser Builder

To set up the old project from the guessing question, we brought angular.json back to how it looked in the Angular 16 days: @angular-devkit/build-angular:browser, main, vendorChunk, buildOptimizer. In Angular 22.2 this builder still works but warns on every build; the webpack builders were officially deprecated in 22.0:

The "@angular-devkit/build-angular:browser" builder is deprecated as part of Angular's Webpack support deprecation. Use "@angular/build:application" instead.
Initial chunk files         | Names                    |  Raw size | Estimated transfer size
main.7660d202c1f8464c.js    | main                     | 842.59 kB |               191.68 kB
runtime.c180557d932b4ee4.js | runtime                  |   3.05 kB |                 1.41 kB
styles.438ac3e8faf16184.css | styles                   |  78 bytes |                55 bytes

                            | Initial total            | 845.73 kB |               193.14 kB

The command the Angular docs recommend for the migration:

npx ng update @angular/cli --name use-application-builder
The output location of the browser build has been updated from "dist/bookstore-web" to "dist/bookstore-web/browser". You might need to adjust your deployment pipeline or, as an alternative, set outputPath.browser to "" in order to maintain the previous functionality.
UPDATE angular.json (1677 bytes)
UPDATE package.json (872 bytes)

The changes in git diff (shortened):

-          "builder": "@angular-devkit/build-angular:browser",
+          "builder": "@angular/build:application",
-            "outputPath": "dist/bookstore-web",
+            "outputPath": {
+              "base": "dist/bookstore-web"
+            },
-            "main": "src/main.ts",
+            "browser": "src/main.ts"
-              "buildOptimizer": false,
-              "vendorChunk": true,
-          "builder": "@angular-devkit/build-angular:dev-server",
+          "builder": "@angular/build:dev-server",

-    "@angular-devkit/build-angular": "^22.2.7",
+    "@angular/build": "^22.2.7",

The first answer to the guess is in the migration's own message: the output now lives under dist/bookstore-web/browser. The old deploy script copies the browser folder, index.html is not at the server root and the site does not open. Either change the script to dist/bookstore-web/browser/* or set outputPath.browser to "". buildOptimizer and vendorChunk were also removed; the new builder has no counterpart for them, since optimization already covers everything. In package.json, @angular-devkit/build-angular, which brought in webpack, is gone and @angular/build took its place; in this project node_modules went from 410 MB to 300 MB.

The second answer is more surprising. After the migration main dropped from 842.59 kB to 358.68 kB, and the estimated transfer size from 191.68 kB to 101.98 kB. The difference does not come from esbuild minifying better. moment loads its locale files with a dynamic require such as require('./locale/' + name). When webpack sees this expression it puts all 139 files in the locale folder into the bundle; the Turkish month name "Ocak" is in the old main file and not in the new one. esbuild does not try to resolve a dynamic require and leaves the locale files out. That is good news, with a warning: if your application calls moment.locale('tr') and relies on the locale files, dates silently fall back to English after the migration. This is why you need to compare the build output with the old one after migrating.

On build time, a cold build (no cache) of the same project took about 4 times longer with webpack; with a warm cache on the second build the gap shrank to about 1.6 times. In a small project these seconds do not matter, but the ratio stays similar in large projects and shows up directly in CI time.

Counterparts of Custom webpack Configs

The hardest part of a migration is usually a webpack.config.js added with @angular-builders/custom-webpack. The application builder has no plugin API; instead it has built-in options for the common things people did with webpack. In Bookstore we used two of them: embedding the application version into the code at build time and importing the logo as a file.

"options": {
  "browser": "src/main.ts",
  "define": {
    "BOOKSTORE_VERSION": "'1.5.0'"
  },
  "loader": {
    ".svg": "file"
  }
}
// Values replaced at build time by the "define" option in angular.json
declare const BOOKSTORE_VERSION: string;

// "loader": { ".svg": "file" } turns an SVG import into the URL of the emitted file
declare module '*.svg' {
  const url: string;
  export default url;
}
import logoUrl from './logo.svg';

export class App {
  protected readonly cart = inject(CartStore);
  protected readonly logoUrl = logoUrl;
  protected readonly version = BOOKSTORE_VERSION;
}

After the build, main contains the string 1.5.0 directly in place of BOOKSTORE_VERSION, and the logo is copied as media/logo-X3XO3UBL.svg, with the page using that URL. define can also be passed on the command line, as in ng build --define BOOKSTORE_VERSION="'1.6.0'", and the command-line value wins; that is handy for embedding the commit hash in CI. Counterparts of common webpack needs:

In webpackIn the application builder
DefinePluginthe define option
raw-loader, file-loader, url-loaderthe loader option (text, file, dataurl, base64, binary, empty) or the with { loader: 'text' } import attribute
externalsexternalDependencies
NormalModuleReplacementPluginfileReplacements or production/development conditions with package.json imports
webpack-bundle-analyzerng build --stats-json (in the next post of the series)
Your own webpack pluginNo built-in counterpart; a separate script that runs before or after the build

The last row matters: if your webpack plugin steps into the middle of the build and changes the output, you cannot do the same in the application builder. Most of the time that work can move into a small Node.js script that runs on the dist folder after the build. The community has builders that allow esbuild plugins, but that is not a path the Angular team supports, and you have to check compatibility again with every Angular release.

Browser Target and TypeScript 6.0

Angular defines the browsers it supports with Baseline's "widely available" definition: each major release picks a Baseline date close to its release. For Angular 22 that date is 2026-05-07. In the @angular/build source the date is a constant, passed to browserslist as the query baseline widely available on 2026-05-07. The result: Chrome and Edge 119, Firefox 119, Safari 17 and newer. You can narrow this list with .browserslistrc (for example, to target only the last two versions), but you cannot widen it. We tried adding an old Safari:

Chrome >= 100
Safari >= 15
One or more browsers which are configured in the project's Browserslist configuration fall outside Angular's browser support for this version.
Unsupported browsers:
chrome 118, chrome 117, ..., safari 15.1, safari 15

The build does not stop, it only warns; esbuild tries to transform syntax for these browsers too, but Angular itself is not tested in them. If you add a browser without ECMAScript module support, that browser is ignored entirely, because the Angular CLI does not produce ES5 output.

On the TypeScript side, Angular 22.x requires >=6.0.0 <6.1.0. We covered TypeScript 7.0's compiler written in Go in the TypeScript Advanced series post "TypeScript 7 and Beyond: The Go-Based Compiler and the New Defaults"; the Angular compiler uses TypeScript's JavaScript API and cannot move to it yet. When we installed typescript@7.0.2 in the project, npm only printed a peer dependency warning, and the build stopped with a hard-to-read error:

✘ [ERROR] Angular compilation initialization failed. [plugin angular-compiler]

  TypeError: Cannot read properties of undefined (reading 'Error')
      at readConfiguration (.../@angular/compiler-cli/bundles/chunk-WPHJIKYE.js:507:42)

If you see this error, the first place to look is the TypeScript version in package.json. If the same repository has another project using TypeScript 7 (for example the series' Vite widget), give each project its own typescript dependency, and keep ~6.0.2 in the Angular project.

The Output Folder and outputHashing

The application builder's output:

dist/bookstore-web/3rdpartylicenses.txt
dist/bookstore-web/browser/index.html
dist/bookstore-web/browser/main-KUFRJQIS.js
dist/bookstore-web/browser/chunk-CcmlRWAg.js
dist/bookstore-web/browser/styles-WBKGN4YZ.css
dist/bookstore-web/browser/media/logo-X3XO3UBL.svg
dist/bookstore-web/browser/favicon.ico

The browser folder makes room for SSR. When we added SSR to the same project with ng add @angular/ssr, a server folder (server.mjs, main.server.mjs) appeared next to it, along with files such as browser/cart/index.html for every prerendered route and prerendered-routes.json. index.html is worth a look too: the builder inlines the critical part of the global styles into head (with beasties) and loads the rest of the stylesheet with the media="print" trick so it does not block rendering.

outputHashing decides which file names get a content hash. We tried the four values in turn:

none    -> main.js, styles.css, media/logo.svg, chunk-CJoZx80H.js
media   -> main.js, styles.css, media/logo-X3XO3UBL.svg, chunk-CJoZx80H.js
bundles -> main-RFA2EUMN.js, styles-WBKGN4YZ.css, media/logo.svg, chunk-4bA6SRpc.js
all     -> main-KUFRJQIS.js, styles-WBKGN4YZ.css, media/logo-X3XO3UBL.svg, chunk-CcmlRWAg.js

Two details stand out. Lazy chunks are hashed even with none, because esbuild and Rolldown always generate chunk names with a hash. Second, when we changed the version with define, the hashes of lazy chunks whose content had not changed changed too. The reason is in the chunk's first line: import{...}from"./main-KUFRJQIS.js". The lazy chunk imports main by its hashed name; when the name of main changes, this line changes, and with it the chunk's content and hash. This is the Angular counterpart of the hash chain we saw in the first post of the series: every change that touches main means users download the lazy chunks again as well. Keep the default all in production; the other values only make sense if another tool does the versioning, for example a CDN that versions file names itself.

Production Notes

Do the migration as a separate pull request and compare the output: the file list, the initial bundle size, the CommonJS warnings and how the app behaves in the browser. Some differences, like the moment example, only show up in the running application.

Update your deploy pipeline for the browser folder. A one-line CI step that checks that dist/bookstore-web/browser/index.html exists stops an empty site from going live.

Use builds that rely on environment variables (NG_BUILD_OPTIMIZE_CHUNKS, NG_BUILD_CHUNKS_ROLLDOWN) only while troubleshooting. If you leave them in CI permanently, their meaning may change in an Angular update and you may not notice.

The .angular/cache folder holds the persistent build cache; since 22.1 it is shared across git worktrees. Caching this folder between CI jobs speeds up the second build, but do not forget to include package-lock.json in the cache key.

Common Pitfalls

First, assuming the migration does not change behavior because it does not change code. The bundler changed; dynamic require, imports with side effects and CommonJS packages are handled differently. The Angular docs also list order-dependent side-effect imports across lazy modules running in a different order as a known issue.

Second, trying to configure Vite from inside ng serve. Writing a vite.config.ts changes nothing; Angular's dev server only reads the options in angular.json (prebundle, hmr, proxyConfig, headers).

Third, assuming you set the ng serve port with --port and forgetting the PORT variable in the environment. Since 22.0 the PORT environment variable takes precedence over both the setting in angular.json and --port; if PORT is set on the machine for another tool, the dev server opens on a port you did not expect.

Fourth, thinking you can support old browsers through .browserslistrc. The list can only be narrowed; you get a warning for browsers outside Angular's browser policy, and Angular itself is not tested in those browsers.

Fifth, upgrading TypeScript to 7.0 in the Angular project along with the other projects. npm only warns, and the build stops with the hard-to-read error above.

A Small Experiment

★☆☆ Run npx ng build in snapshot/05/web, then set NG_BUILD_OPTIMIZE_CHUNKS to false and try again. Compare the number of initial files and the estimated transfer size of the two builds. What happens when you set the threshold to 10?

★★☆ Stop loading admin-page.ts as a lazy route and import it directly. When the number of lazy chunks drops to three, does chunk optimization still run? Recall the threshold in the source and confirm your answer with the build output.

★★★ Make two builds with ng build --define BOOKSTORE_VERSION="'1.6.0'" and write a small Node.js script that compares the two dist folders: list the files whose names changed and the files whose content really changed separately. From this difference, find the files users would download again for no reason after a version update.

In the next post we return to the CommonJS warnings we set aside here: the conditions of tree shaking, code splitting and bundle analysis with ng build --stats-json. We will remove moment and lodash from Bookstore and measure how much the initial bundle shrinks.