Angular v17 → v22: The Migration Guide That Includes the Breakages

On this page21 sections
If you are running Angular 17, 18, or 19 in production today, you are running software that receives no security patches. Angular 17 stopped getting them in May 2025. Angular 18 in November 2025. Angular 19 in May 2026.
That is the short version, and for a lot of teams it is the whole conversation. But "you should upgrade" is useless advice without an honest answer to "what will it actually cost," and most migration guides do not give you one. They tell you to run ng update and wish you luck.
I have run this migration. This is what the guides leave out.
The support clock
Angular ships a major version every six months. Through v21, each one got 18 months of support: six months active, twelve months of security-only LTS. Starting with v22, that widened to 24 months, and Angular's docs now hint the six-month cadence itself is under review.
| Version | Released | Security support ends | Where that leaves you |
|---|---|---|---|
| v17 | Nov 2023 | 15 May 2025 | End of life |
| v18 | May 2024 | 21 Nov 2025 | End of life |
| v19 | Nov 2024 | 19 May 2026 | End of life |
| v20 | May 2025 | 28 Nov 2026 | LTS, expiring soon |
| v21 | Nov 2025 | 30 Jun 2027 | LTS |
| v22 | 3 Jun 2026 | ~30 Jun 2028 | Active |
The gap between "we're only a couple of versions behind" and "we are unsupported" is smaller than most teams assume. Angular 17 felt current in early 2024. It was dead eighteen months later.
This is not a theoretical risk
Three CVEs were disclosed across the Angular platform in late 2025. HeroDevs, who sell extended support for end-of-life Angular and therefore have an interest in you being worried, documented them:
- CVE-2025-59052 (CVSS 7.1) - a race condition in the SSR pipeline that could leak request-scoped data between concurrent requests
- CVE-2025-66035 (CVSS 7.7) - HttpClient leaking XSRF tokens via protocol-relative URLs
- CVE-2025-66412 - stored XSS in the template compiler, related to SVG and MathML attribute sanitization
The CVE identifiers check out independently. Teams on end-of-life versions got patches for none of them.
If you are in a regulated industry, or you fill out security questionnaires for enterprise customers, this is the line item that eventually forces the decision anyway. Better to make it on your schedule.
What it costs
You cannot jump straight from 17 to 22. The migration schematics are written to run between adjacent majors, so 17 → 22 means five sequential upgrades. Skipping versions means skipping the schematics that handle them, and you find out months later when something renders wrong.
Published estimates vary wildly, and it is worth knowing who is saying what:
| Source | Estimate | What it's good for |
|---|---|---|
| Arc (recruiting site) | v19/v20 → current: 2-8 hours. v17/v18: 3-6 days | Best case. Small, clean, well-tested app. |
| FrontendMinds (agency, 19 enterprise projects) | 1-3 days per major for small apps, 1–-2 weeks for medium enterprise | Reasonable planning baseline |
| OctoPerf (published their actual numbers) | 7,786 files, ~381,000 lines. Three weeks of framework work after a failed first attempt. One commit rewrote 5,221 files. | What large actually looks like |
The first two are marketing. The third is someone showing their working.
My own experience: In one of my large scale enterprize project it will take 2-3 week to complete one major version upgrade. For a real enterprise application with a themed Material UI, a handful of ngx-* dependencies, and a test suite that stopped being maintained around 2022, one to two weeks per major hop is a fair planning number.
But the version bumps are not where the money goes.
The four things that actually cost money
Almost every migration overrun I have seen traces back to one of these, and none of them is a version number.
1. Angular Material M2 → M3 theming. The official schematic produces output that looks broken. This is a design project wearing an upgrade costume, and it needs design QA time you probably did not budget.
2. The Webpack to esbuild builder switch. Painless if your build config is stock. Weeks if you have custom Webpack plugins, ngx-build-plus, or Module Federation, because none of that transfers automatically.
3. The zoneless and OnPush transition, and the test fallout behind it. fakeAsync and tick do not survive going zoneless. Neither does Karma, eventually.
4. Third-party library lag. PrimeNG, NgRx, and the long tail of ngx-* packages set your ceiling. Angular is rarely the blocker. Your dependencies are.
The distinction nobody makes: must-do versus optional
This is the part I wish someone had told me first. Upgrading Angular is not the same thing as adopting modern Angular. Confusing the two is how a two-week upgrade turns into a two-quarter rewrite that nobody approved.
Must do - the build breaks without it
| Requirement | Which hop | Why |
|---|---|---|
| Node and TypeScript bumps | Every hop | Build fails immediately |
standalone: false on NgModule-declared components | v19 | Default flipped |
Drop this. on template context variables | v19 | Silent compiler behavior change |
Rename afterRender() → afterEveryRender() | v20 | No automatic migration exists |
Replace TestBed.flushEffects() | v20 | Removed |
Accept the ChangeDetectionStrategy.Eager migration | v22 | Preserves existing behavior |
| Audit route param reads | v22 | Inheritance default changed |
| Fix duplicate input bindings | v22 | Now a compile error |
| Material and CDK in lockstep with core | Every hop | Peer deps are pinned |
Optional - genuinely optional, whatever the release notes imply
| Modernization | Available from | When to do it |
|---|---|---|
| Signals | v18 stable | Incrementally, in new code |
Control flow (@if / @for) | v17 | Its own ticket, not mid-upgrade |
inject() over constructor injection | v14 | Opportunistically |
| Zoneless change detection | v20 stable, v21 default for new apps | After hot paths are on OnPush |
| Vitest over Karma | v21 default for new apps | When the suite is stable |
| Signal Forms | v22 stable | v22 is the green light |
| Angular Aria | v22 stable | Only for new accessible components |
| Selectorless components | v22 | No urgency |
| v20 filename conventions | v20 | Never, if you don't want to. It's a style guide. |
The rule I give teams: during a version hop, do only the first table. Everything in the second gets its own ticket, shipped independently. A migration PR that also converts four hundred templates to @if is a PR nobody can review, and unreviewed migration PRs are how regressions reach production.
The plan
Five phases. Each one ships to production on its own. If a phase cannot ship alone, it is too big.
Phase 0 - Assessment (one sprint, no code changes)
Inventory every third-party Angular dependency and map its supported version window. This determines your ceiling before you write a line of code. Flag PrimeNG, NgRx, Material theming, @angular-builders/custom-webpack, ngx-build-plus, Module Federation, i18n, and anything still on @nguniversal/*.
Then audit your test coverage honestly. If the suite is thin or flaky, stop and fix that first. The team at OctoPerf put it well after their migration: their bottleneck was validation, not transformation. They relied on 10,919 unit tests to verify 5,221 changed files, because reviewing those files by hand was never going to happen.
Check Node and TypeScript versions in four places, not one: local dev, CI images, Dockerfiles, and the engines field.
Phase 1 - Get to a supported version (v17 → v20)
v17 → v18 is the quiet hop. Mostly deprecations. You will be prompted to migrate the builder - decline for now if you have a custom Webpack config, and handle it separately in Phase 2.
v18 → v19 is the standalone flip, and the most consequential single change in the range. The migration adds standalone: false to everything currently declared in an NgModule. It mostly works. Afterwards, manually verify every dynamic component load and every template that used this.foo, because v19 changed what that means and the compiler will not warn you.
v19 → v20 requires two manual renames the schematic will not do for you: afterRender() becomes afterEveryRender(), and TestBed.flushEffects() is gone.
Ship this. You are now on supported software, and the security argument is satisfied. Everything past this point is engineering value rather than risk mitigation, which makes it a much easier conversation with whoever holds the budget.
Phase 2 - The hard workstreams (separately, any order)
The builder migration. Material M2 → M3. The control flow migration. Any third-party overhaul like PrimeNG's theming rewrite or @nguniversal/* → @angular/ssr.
Each of these is its own project with its own QA. Bundling them into the version hops is how migrations stall out and get quietly abandoned.
Phase 3 - v20 → v22
v20 → v21 makes zoneless and Vitest the defaults for new apps. Your existing app keeps zone.js and Karma. Do not change both at once.
v21 → v22 requires Node 22+ and TypeScript 6.0+ before you run ng update, not after. The build fails immediately otherwise. Check your CI images and Dockerfiles, not just your laptop.
Let the Eager migration run and do not fight it. Then audit anything reading route params, because the router now inherits params from all parent routes by default.
Phase 4 - Modernization (ongoing, forever)
Peel components off Eager onto OnPush. Move hot paths to signals. Enable zoneless once OnPush coverage is good. Migrate Karma to Vitest. Adopt Signal Forms in new code.
None of this is urgent. All of it is worth doing eventually.
What breaks, version by version
v18 → v19
standalone: true became the default. Every component, directive, and pipe is standalone unless you say otherwise.
The migration handles most of it, but two issues on the Angular tracker are worth knowing about. In #58747, ng update failed to remove the flag from components without imports. In #57829, dynamic component loading via ViewContainerRef.createComponent and createNgModule broke for components relying on the implicit old default.
The one that costs people real debugging time: this.foo in a template no longer refers to a template context variable. If you meant the template variable, drop the this.. Nothing errors. The value is just wrong.
Also in v19: TypeScript below 5.5 dropped, effect() timing changed to run as part of change detection rather than as a microtask, and event replay became stable and on by default for new SSR apps.
v19 → v20
Signals largely finished. effect(), linkedSignal(), and toSignal() all went stable.
Two manual fixes, no schematic: afterRender() renamed to afterEveryRender(), and TestBed.flushEffects() removed.
*ngIf, *ngFor, and *ngSwitch were officially deprecated in favour of control flow. Structural directives in general were not deprecated, only those three. The style guide also changed filename conventions and started recommending [class.x] over ngClass. All of that is optional.
TypeScript 5.8 and Node 20 became required.
v20 → v21
Zoneless became the default for new apps, and zone.js is no longer shipped with them. Existing apps are not forced off it. Vitest replaced Karma and Jasmine as the default runner, though Karma still works. Signal Forms arrived as experimental, Angular Aria as developer preview, and the CLI gained a built-in MCP server with an onpush_zoneless_migration tool.
The trap: because Vitest always runs zoneless in Angular, fakeAsync and tick cannot be used with it. There is also a documented regression (angular-cli#32121) where fakeAsync tests fail with zone-testing.js is needed for the fakeAsync() test helper but could not be found under the new unit-test builder, even when you are still using Karma.
v21 → v22
Released 3 June 2026. Four things break.
OnPush is now the default change detection strategy. A component that does not set changeDetection means OnPush, not check-always. Nothing in your existing code breaks if you let the migration work - ng update stamps the new ChangeDetectionStrategy.Eager value onto every component that relied on the old default. What changes is that every new component your team generates is OnPush. If people still write code assuming mutation-based updates will show up, v22 is where that habit stops working.
Route params now inherit from all parent routes. The paramsInheritanceStrategy default changed from 'emptyOnly' to 'always'. A child route reading a param name that also exists on a parent may now resolve to a value it never saw before, and code with defensive "if this param is undefined" branches may silently take the other path.
You can revert it globally
provideRouter(routes, withRouterConfig({
paramsInheritanceStrategy: 'emptyOnly',
}))But renaming colliding params (:userId instead of a second :id) is the better fix. The new default is genuinely more useful for deep-linked detail pages.
Node 22 and TypeScript 6.0 are mandatory. Node 20 is dropped. Node 26 is supported.
And three smaller edges: duplicate input bindings on one element are now a compile error rather than silently tolerated, data-* attributes bind as attributes instead of property bindings, and the HTTP transfer cache skips credentialed requests by default so authenticated responses cannot leak across SSR sessions.
On the plus side, v22 is where the modern stack stops being experimental. Signal Forms, resource() and httpResource(), Angular Aria, injectAsync(), and HttpClient with Fetch all went stable. Incremental hydration became the default. Selectorless components arrived.
Where ng update leaves you on your own
The schematics handle maybe seventy percent of this mechanically. The remaining thirty is where the budget goes.
Control flow migration is good but needs a human reading every diff. It drops i18n attributes on ng-template (#53289, #52517), which silently breaks translations with no error. It mishandles ng-template combined with [ngIf] and let- (#53291). It leaves orphaned named templates behind (#58850). And it introduces whitespace around inline elements, which corrupts rendered text in ways that only show up when someone reads the page.
Peer dependency conflicts stop ng update cold, usually with Migration failed: Incompatible peer dependencies found. Your options, ranked honestly:
overridesin package.json. Explicit, reviewable, documented. Use this.--legacy-peer-deps. Acceptable. Leaves the conflict unresolved but breaks nothing.--force. Avoid. It can leave you with duplicate package versions innode_modulesand runtime errors that make no sense.
Whatever you use, follow it with a full build, a serve, and the test suite.
Material M2 → M3 is the worst gap between what the docs promise and what you get. It has its own post in this series, because it deserves one.
What you actually get
The build speed argument is the easiest one to make to a CTO, because Angular measured it themselves. The v17 release reported up to 87% faster ng build, an 80% faster edit-refresh loop, and enterprise partners seeing 67% build-time improvements on the esbuild pipeline.
Beyond that: dropping zone.js removes roughly 33KB raw from the bundle and eliminates a large share of unnecessary change-detection cycles. Incremental hydration and event replay improve Time to Interactive without losing interactions that happen before hydration. And modern Angular is what people you hire already know - a codebase full of NgModule wiring takes longer to onboard into, every single time.
The honest summary
Angular's release notes describe v18 as having few breaking changes and present the standalone and Material migrations as automated. The issue trackers tell a different story, particularly for M3 theming, where the official documentation was internally contradictory enough that developers filed issues about the documentation itself.
That gap is the whole point of this article. ng update is genuinely good. It is not the plan.
If you are on 17, 18, or 19, the first move is not "adopt signals" or "go zoneless." It is Phase 1: get to a supported version, ship it, and stop being an audit finding. Everything else can wait for a quarter when it fits.
We do this work. If you have an Angular codebase that has fallen behind and you want a realistic scope before you commit budget to it, contact us.
Need engineers who stay?
Dedicated developers at $3,500 per developer per month, working your backlog on 2-week sprints with a named technical lead.



