Angular 6 → 22: the upgrade path and where you trip up
On this page
Rule number one: one version at a time
Angular releases a major version roughly every six months. The upgrade command:
npx ng update @angular/core@17 @angular/cli@17ng update doesn’t just change the number in package.json. It runs migrations — scripts that rewrite your code for the new version. Migrations are written for going from N to N+1. Jump from 12 to 17, and the migrations for 13, 14, 15 and 16 won’t run, and you’ll have to sort it out by hand.
So the cycle is:
- Upgrade by one version.
- Build, run the tests, open the app.
- Commit it as a separate commit.
- Next version.
A separate commit per version means you can roll back exactly one step and use git bisect to find which one broke something.
For an Nx monorepo, instead of ng update there’s nx migrate: it upgrades Angular, Nx itself and the plugins consistently.
npx nx migrate 21 # пишет новые версии в package.json и migrations.json
pnpm install
npx nx migrate --run-migrationsAnd check against the official Angular Update Guide: you pick “from” and “to”, and it shows what to do by hand.
The map: where the big steps are
Most upgrades are “upgraded, fixed a couple of warnings, moved on”. But there are a few steps where something fundamental changes.
| Version | What changes | Why it’s risky |
|---|---|---|
| 6 | ng update, RxJS 6 | old RxJS imports (rxjs/add/operator/...) have to be rewritten to pipe() |
| 8 | lazy loading via import() | strings like './module#Module' in routes are deprecated |
| 9 | Ivy engine by default | old libraries built for View Engine; stricter template checks |
| 12–13 | View Engine removed, IE11 no longer supported | libraries without Ivy support stop working entirely |
| 14–15 | standalone components, typed forms | you can keep the old way, but new code is written differently |
| 16–17 | signals, new control flow (@if, @for), esbuild-based builder | a huge but mechanical template refactor |
| 19 | standalone: true by default | components with NgModule need an explicit standalone: false |
| 20–22 | new builder, zone.js leaves the defaults, CDK changes overlays | details below |
In practice the most painful steps are 9 and 13: the problem is usually not your code but dependencies nobody updated for Ivy. There’s one solution: replace the library or remove it.
How it went for us
Before the monorepo we had more than ten copies of the app, each upgraded separately — which in practice meant not upgraded. The move to a monorepo (details in nx-monorepo-from-copies) started on Angular 16. Then:
- 16 → 17 — a couple of months after the start, in a separate commit.
- 17 → 19 — a year and a half later. That was a mistake: the longer you stand still, the more piles up.
- 19 → 20 → 21 → 22 — in one day, in three separate commits, together with Nx 20 → 23 and TypeScript 5.7 → 6.0.
The last jump was quick precisely because the code is shared: the migrations ran across all apps at once. But “in one day” is only the upgrade itself. Sorting out the consequences took another two weeks.
Step 20: the new builder
The old @angular-devkit/build-angular:browser builder (webpack-based) was replaced by @angular/build:application (esbuild-based). In each app’s project.json:
// было
"executor": "@angular-devkit/build-angular:browser",
"options": { "main": "src/main.ts", "buildOptimizer": true, "vendorChunk": true }
// стало
"executor": "@angular/build:application",
"options": { "browser": "src/main.ts" }Some options simply disappeared (buildOptimizer, vendorChunk, namedChunks) — the new builder doesn’t need them. The output structure changed too: files now live in dist/<app>/browser/. If your deploy copies dist/<app>/, an extra folder ends up on the server. We caught that in CI.
The new builder is noticeably faster. But it’s stricter about module order — which leads to the next point.
Pitfall: import cycles
After the upgrade some pages started crashing at runtime with NG0919 (“Cannot read @Component metadata”). The build passed.
The cause is a circular import. An example:
// list.page.ts
import { EditDialog } from './edit.dialog'; // страница открывает диалог
// edit.dialog.ts
import { ListPage } from './list.page'; // диалог берёт тип у страницы
Modules in a cycle load in an unpredictable order. When Angular reads imports: [EditDialog], the EditDialog class may still be undefined. The old builder happened to load them in a “lucky” order. The new one doesn’t.
How to break the cycle, from best to worst:
- Move the shared part into a third file. Constants, tokens, interfaces go into
*.constants.tsor*.interface.ts, which both depend on. - Import only the type. If you only need the type,
import type { ListPage } from './list.page'. TypeScript removes such an import at compile time, and there’s no cycle at runtime. - Weaken the type. For example,
MatDialogRef<any>instead ofMatDialogRef<EditDialog>. It works, but loses type checking.
To be honest: in a few places we went with the third option to ship the upgrade faster. That’s debt, and it’s written down. The right one is the second.
And to keep cycles from appearing between libraries, you need strict boundaries: dependencies in one direction only, checked by the linter.
Pitfall: toasts under dialogs
After upgrading Angular CDK to 22, pop-up notifications (toasts) started appearing under modal windows.
The reason: CDK 22 by default renders overlays (dialogs, dropdowns, tooltips) through the Popover API — in the browser’s so-called top layer. The top layer is drawn above the whole document, and z-index has no effect on it. A toast with z-index: 1001 in the normal document ends up under a dialog in the top layer, however high you push the number.
We bring back the old behaviour with one provider:
import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay';
providers: [
{ provide: OVERLAY_DEFAULT_CONFIG, useValue: { usePopover: false } },
]Lesson: read the changelog not only for Angular but also for CDK and Material. They’re upgraded together, but they change behaviour separately.
Step 21: zone.js and HttpClient
zone.js is a library that intercepts all asynchronous events (clicks, timers, HTTP) and tells Angular, “something happened, check the screen”. Angular is moving away from it towards signals, and new apps are created without zone.js.
For an existing app, the migration during the upgrade added an explicit provider so that nothing changed:
bootstrapApplication(AppComponent, {
...appConfig,
providers: [provideZoneChangeDetection(), ...appConfig.providers],
});That’s the right behaviour for a migration: an upgrade shouldn’t change behaviour unnoticed. Going zoneless is a separate task, with separate testing.
Likewise the migration added withXhr() to provideHttpClient(...) so the HTTP client stays on XMLHttpRequest. If you have file uploads with progress, check them after the upgrade.
After the upgrade: polishing
Upgrading the version isn’t the end. The new Angular offers new ways to write code, and it’s worth moving to them gradually, or in a couple of years you’ll have “old code” again:
inject()instead of constructor injection;- signals (
signal,computed) for component state; @if/@forinstead of*ngIf/*ngFor;takeUntilDestroyed()for subscriptions.
We did the move to inject() as a separate task: one commit per library. That keeps reviews small, and if something breaks, it’s clear where. For most of it there are official automatic migrations (ng generate @angular/core:inject and the like) — run them rather than rewriting by hand.
Checklist
- One major version at a time, a separate commit.
ng update/nx migrate, not hand-editingpackage.json.- After each: build, tests, a look at the app.
- Before starting — a list of dependencies: which ones don’t support the target version.
- The changelog for Angular and CDK/Material.
- New runtime errors (NG0919 and the like) — look for import cycles first.
- Don’t stay on one version for a year and a half: two upgrades a year are cheaper than one every three years.
Related posts: nx-monorepo-from-copies, tailwind-v3-to-v4-monorepo, ci-nx-affected.