---
title: 'Angular 20 to 22 Migration: Why We Stopped Going Incremental'
url: https://blog.octoperf.com/angular-20-to-22-migration-why-we-stopped-going-incremental/
date: '2026-07-28'
authors:
- dregnier
- gpereira
categories:
- Development
- Innovation
tags:
- Angular
- Artificial Intelligence
- Application Performance
- Development
description: Why migrating Angular 20 to 22 component by component failed, how 154 Playwright tests written with Claude let us do it in one 5,221-file pull request, and what we measured after going zoneless and switching to Vitest.
---

# Angular 20 to 22 Migration: Why We Stopped Going Incremental

The [OctoPerf](https://api.octoperf.com/) web application is a large Angular workspace: **7,786
TypeScript files, around 381,000 lines of code, 1,166 components and 2,738 test files**. We have
just taken it from Angular 20 to Angular 22, deleted `zone.js`, and replaced Karma and Jasmine with
[Vitest](https://vitest.dev/).

The interesting part of this story is not the version number. It is that **we got the strategy wrong
first**. We spent months migrating the codebase component by component, drowned in manual regression
testing, and still let bugs reach production. What unblocked us was not a better migration
technique. It was realising that **our bottleneck was validation, not transformation**, and building
an automated safety net before touching the framework again.

So this post is the thought process rather than the changelog, with the numbers we measured rather
than the ones we hoped for. It ends on a question we cannot dodge: this migration would not have
happened in this timeframe without an LLM. So are we now dependent on one?


## Table of Contents

- [Where We Started: Angular 20 and 5,000 Files](#starting-point)
- [First Strategy: One Component at a Time](#incremental-strategy)
- [The Realisation: Our Bottleneck Was Validation](#the-realisation)
- [Writing the Safety Net Before the Migration](#e2e-safety-net)
- [Then One Giant Pull Request](#one-giant-pull-request)
- [The Actual Work](#the-actual-work)
    - [Signals, in Six Phases](#signals-migration)
    - [Standalone Routes and Modern Bootstrapping](#standalone-routes)
    - [Modern Control Flow and the Track-By Cleanup](#control-flow)
    - [Dropping @angular/animations for Native CSS](#native-css-animations)
    - [RxJS Reentrancy: The Bugs We Found on the Way](#rxjs-reentrancy)
- [Going Zoneless: Where the Performance Came From](#zoneless-performance)
- [Tests: Karma and Jasmine Out, Vitest In](#vitest-migration)
- [Could We Have Done This Without Claude?](#migration-with-claude)
- [Are We Becoming Dependent on LLMs for Our Migrations?](#llm-dependency)
- [One Major a Year Is a Relief](#angular-annual-releases)
- [Conclusion](#conclusion)

## Where We Started: Angular 20 and 5,000 Files { #starting-point }

![A building wrapped in scaffolding](https://blog.octoperf.com/img/blog/angular-20-to-22-migration/building-under-scaffolding.jpg "A whole codebase under scaffolding at once")

Our front end is an Angular workspace with three projects: the **OctoPerf application** itself, a
**shared component library** that carries our tables, trees, charts and form controls, and a
**sandbox** we use to develop components in isolation. The library alone holds 653 test files.

On Angular 20.3 the code worked, but it carried a decade of accumulated idiom. Decorator-based
`@Input()` and `@Output()`, `@ViewChild()` queries, `NgModule` declarations, `@HostBinding`,
`*ngIf` and `*ngFor` structural directives, `@angular/animations`, and `zone.js` underneath all of
it. Worse, the code was littered with **manual `ChangeDetectorRef.detectChanges()` calls**, the
tell-tale sign of a codebase fighting its own change detection rather than describing its state.

We had two reasons not to stand still. The first is boring: **staying two majors behind means
losing security patches**, since Angular supports a major for 12 months of active support plus 12
months of LTS. The second is that Angular 22 finally shipped the thing we actually wanted, which
is **change detection without zones**.

## First Strategy: One Component at a Time { #incremental-strategy }

We started the way every article on the subject tells you to. **Incrementally, from the bottom
up.** Migrate the shared library first, component by component, and let the application follow.

The unit of work was small on purpose. One pull request migrated one family of components. Another
converted the selectors of one area. Around **thirteen pull requests** went through this way: the
library, then the sandbox, then the application area by area, signals in six numbered phases. Each
diff was small enough that a human could read it in one sitting, which was exactly the point.

Then we hit the problem that nobody warns you about. **A small diff in a shared component library
is not a small change.** When a table, a tree or a form control changes its inputs, every screen
using it is affected, and our unit tests confirmed only that the component itself still behaved.
They could not tell us that the virtual user editor still scrolled, or that a report still exported.

So after every one of those pull requests, somebody had to **click through the application by
hand**. Not the changed screen, the whole application, because we could not predict what a shared
component would break three levels up.

The outcome was the opposite of what incremental delivery is supposed to give you:

- we spent **more time testing manually than migrating code**,
- the manual passes were long, so they got shorter and less careful as the weeks went on,
- and **we still shipped bugs to production**, because a human clicking through a load testing
  platform for the fifteenth time does not check the same things as the first time.

## The Realisation: Our Bottleneck Was Validation { #the-realisation }

Once we stopped and looked at where the time actually went, the diagnosis was uncomfortable but
simple. **We were optimising the wrong half of the problem.**

Splitting the migration into small pieces makes each *transformation* cheap to review. It does
nothing for the *validation*, and validation was the expensive part. Worse, splitting actively
multiplies it: thirteen pull requests meant thirteen full manual regression passes, each one
covering almost exactly the same ground as the last.

Which flips the arithmetic. **If validation is automated, a big diff costs no more to validate
than a small one.** One migration, one test run. The reason to go incremental was to keep
validation manageable, and if that reason disappears, so does the strategy.

That gave us the order of operations we should have started with: **build the safety net first,
then migrate in one pass.**

## Writing the Safety Net Before the Migration { #e2e-safety-net }

We had a [Playwright](https://playwright.dev/) smoke suite, but nothing that resembled a full
functional description of the product. So before touching Angular again, we built one, and we built
it in two layers.

**Layer one is written in English.** We had Claude walk the entire user interface and produce, area
by area, a reference of every feature, every control and every expected behaviour. The result is
**57 markdown documents, around 7,000 lines**, numbered from `00-test-environment` to
`56-account-session-disconnect`. It is deliberately dual-purpose: a human can run any file as a
manual QA checklist, and it is the source the automated specs are derived from.

**Layer two is the automation.** From those documents came **49 Playwright specs holding 154 test
cases**, covering the product end to end:

```
01-access-login          15-admin-users              39-design-servers
05-account-profile       23-admin-workspaces         44-runtime-scenarios-list
09-workspace-list        26-design-vu-editor-...     46-runtime-test-launch
12-workspace-audit-logs  32-design-vu-editor-...     50-analysis-report-view
14-workspace-providers   36-design-vu-editor-...     52-analysis-report-trend
```

![The end-to-end test pull request: 142 files, 11,646 additions](https://blog.octoperf.com/img/blog/angular-20-to-22-migration/e2e-tests-pull-request.png "The safety net landed on 18 June, two weeks before the migration")

That landed on **18 June 2026**, two weeks before the Angular 22 work started. The ordering is the
whole point of this post. **The tests are not a consequence of the migration, they are its
precondition.**

Two things are worth saying about writing them with an LLM. First, describing 57 areas of a mature
product in prose is exactly the kind of high-volume, low-ambiguity work a human team postpones
forever and an LLM does in days. Second, the English layer turned out to be more valuable than we
expected: when a spec fails, the reference document tells you what the behaviour was *supposed* to
be, in a form anybody can read.

## Then One Giant Pull Request { #one-giant-pull-request }

With the net in place, the framework work took three weeks. Angular 21 landed on 29 June, Angular
22 on 1 July, and the consolidated refactoring on 16 July, this last one rewriting **5,221 files**
for **+182,379 and -182,019 lines**.

![The migration pull request: 182,379 additions and 182,019 deletions](https://blog.octoperf.com/img/blog/angular-20-to-22-migration/pull-request-diff-stat.png "GitHub gave up counting the files and just said 3000+")

GitHub refuses to display that diff properly and caps the file list at 3,000. Its shape tells you
where the work actually was:

- **2,758 `.spec.ts` files**, which is the entire Jasmine to Vitest rewrite,
- 2,217 other TypeScript files, which is signals, `inject()` and zoneless cleanups,
- 225 HTML templates.

Nobody reviewed that file by file, and pretending otherwise would be dishonest. **What we reviewed
was the strategy, the sample and the signal**: read a representative slice of each mechanical
transformation to confirm the pattern was right, then trust 10,919 unit tests and 154 end-to-end
tests to catch the rest. That is only a defensible trade because the second half of that sentence
exists.

## The Actual Work { #the-actual-work }

### Signals, in Six Phases { #signals-migration }

The bulk of it was converting the codebase to **signal-based APIs**: `input()` and `output()`
instead of the `@Input()` and `@Output()` decorators, `viewChild()` and `contentChild()` instead of
the query decorators, `signal()` and `computed()` for local component state instead of plain class
properties.

This is the piece we shipped the slow way, in **six numbered phases** plus dedicated passes for the
library and the sandbox, and it is the piece that taught us the lesson above. Six phases meant six
manual regression passes over the same screens. Reviewable diffs, unreviewable total cost.

The payoff is worth it though, and it is not syntactic sugar. A signal-based component **declares
its dependencies**, so the framework knows precisely what to re-render when something changes. That
is what makes the next section possible.

### Standalone Routes and Modern Bootstrapping { #standalone-routes }

`NgModule` went away. The application now bootstraps through `bootstrapApplication()` with
provider functions, and every route is standalone and lazy by default. This is one of the places
where Angular's own tooling did the heavy lifting: the `@angular/core:standalone` schematic runs in
three modes, converting components, pruning the now-empty modules, then rewriting the bootstrap.

### Modern Control Flow and the Track-By Cleanup { #control-flow }

All templates moved from `*ngIf` and `*ngFor` to the built-in `@if`, `@for` and `@switch` blocks,
again with the official `@angular/core:control-flow` schematic.

The interesting part was the fallout. The `@for` block **requires a track expression**, and
Angular is loud when the tracking is wrong. A dedicated pass went through every console error
about duplicate keys. Those were real bugs, silently rebuilding DOM subtrees on every change
detection cycle for years. If you have ever wondered why that matters, we wrote about
[`ngFor` and `trackBy`](https://blog.octoperf.com/angular-performance-optimization---ngfor-trackby/) a while ago, and about
[avoiding call expressions in templates](https://blog.octoperf.com/angular-performance-optimization---no-call-expression/).

### Dropping @angular/animations for Native CSS { #native-css-animations }

The `@angular/animations` package went out entirely, replaced by **plain CSS transitions and
keyframes**. The Angular animations engine is a runtime that has to be shipped, parsed and
executed to do what the browser's compositor does natively and for free. Removing it deleted a
dependency and made the animations smoother.

### RxJS Reentrancy: The Bugs We Found on the Way { #rxjs-reentrancy }

This one deserves its own issue, and it got one. We audited the whole codebase for three related
anti-patterns: **calling `detectChanges()` inside a `subscribe`**, calling `subject.next()` inside
a subscription of that same subject, and nesting subscribes inside each other.

All three are reentrancy hazards. All three "work" under `zone.js` because the zone re-runs change
detection often enough to paper over the ordering problem. **None of them survive a move to
zoneless**, which is exactly why the audit had to happen before the switch and not after.

## Going Zoneless: Where the Performance Came From { #zoneless-performance }

Here is the single fact that captures the change. In our Angular 20 production build,
`polyfills.js` contained one line of source, `import 'zone.js';`, and shipped **38,978 bytes of
minified JavaScript, 12,251 bytes gzipped**. In the Angular 22 build, the same file is **0 bytes**.
The polyfills bundle is empty.

But the bytes are the least interesting part. What `zone.js` actually did was
**monkey-patch every asynchronous API in the browser**: `setTimeout`, `addEventListener`,
`Promise`, `XMLHttpRequest`, and dozens more. Every patched callback told Angular "something might
have changed", and Angular responded by **checking the entire component tree**. On a screen like our
[load testing reporting engine](https://octoperf.com/product/load-testing-reporting-engine/) with
thousands of table rows and live charts, a mouse move could trigger a full-tree check.

Angular 22 replaces that with `provideZonelessChangeDetection()`. There is no zone, no patching,
and no global "something happened" signal. Components are refreshed because a signal they read
changed, or because an `async` pipe emitted. Two consequences we did not fully anticipate:

- **`OnPush` is now the default** in Angular 22 for any component that does not set
  `changeDetection` explicitly, so the framework's default and our convention finally agree,
- **`NgZone` and `runOutsideAngular()` no longer exist for us**, and every one of those calls,
  each a small performance workaround somebody wrote years ago, got deleted.

The `ChangeDetectorRef.detectChanges()` calls went too, and if you want the long version of why
those were a smell in the first place, we covered
[Angular change detection performance](https://blog.octoperf.com/angular-performance-optimization---onpush-change-detection/) years ago. That is
the part the team notices daily:
the code no longer contains instructions telling the framework when to look at the screen. It
describes state, and the framework works out the rest. We test performance for a living, so we
find it fitting that the biggest win came from **removing code rather than adding it**.

## Tests: Karma and Jasmine Out, Vitest In { #vitest-migration }

This is where the honest reporting starts, because we measured the before and after rather than
quoting a marketing figure. We checked out the Angular 20 commit in a separate worktree, installed
it, and ran both suites on the same machine on the same day.

| | Angular 20, Karma + Jasmine | Angular 22, Vitest |
|---|---|---|
| Library: tests | 2,824 | 3,427 |
| Library: wall clock | 58 s | 60 s |
| Application: tests | 8,583 | 7,492 |
| Application: wall clock | 115 s | 87 s |
| **Total wall clock** | **173 s** | **147 s** |

The test counts are not strictly comparable, since the rewrite reorganised part of the suite, but
the machine, the day and the coverage settings are the same.

So the full suite is about **15% faster end to end**, not an order of magnitude. Where the gain is
unambiguous is the build step that precedes the tests: the Karma pipeline spent roughly 45 seconds
generating webpack bundles for the library, while the esbuild-based Vitest builder does it in
**17.6 seconds**.

The number that does not appear in that table matters more. **The Angular 20 suite did not
finish.** On the first run, Chrome disconnected at test 4,077 of 8,583 with "no message in 30000
ms". We only got a complete Karma run by raising `browserNoActivityTimeout` from 30 seconds to 600.
That configuration file was full of similar scar tissue, including a comment disabling parallel
execution in CI "to avoid memory issues". Vitest ran both projects green on the first attempt.

Then there is the coverage, which is where we learned something uncomfortable.

![The Vitest coverage report, with HTML templates listed alongside their components](https://blog.octoperf.com/img/blog/angular-20-to-22-migration/vitest-coverage-report.png "The templates are in the report now")

Under Karma, our library gate was **100% flat**: 100% of statements, branches, functions and lines,
out of 13,630 statements. We were proud of it. Under Vitest, the same library reports
**95.91% of statements, out of 22,280**.

The gate went down. The measured surface went up by **63%**, and the reason is the interesting
part. Karma measured instrumented TypeScript. Vitest measures V8 coverage of the compiled output
and maps it back through source maps, which means **our 1,146 HTML templates are now in the
report**, each with its own statement, branch and function percentages, and its own list of
uncovered lines:

```
File                                             | % Stmts | % Branch | % Funcs | % Lines | Uncovered
 table/commons/table-cell/table-markdown-cell    |   87.87 |      100 |      80 |   95.45 |
  table-markdown-cell.component.html             |   77.77 |      100 |     100 |      75 | 6
  table-markdown-cell.component.ts               |   91.66 |      100 |      80 |     100 |
 table/tree/tree-table                           |   68.75 |    65.67 |   83.33 |   78.84 |
  tree-table.component.html                      |       0 |        0 |       0 |       0 | 1-59
  tree-table.component.ts                        |   97.65 |    78.72 |     100 |   99.15 | 94
```

Read that last block again. `tree-table.component.ts` sits at 97.65% while its template is at
**zero**, because no test ever rendered it. Our old 100% was 100% of the TypeScript, and it told
us nothing at all about the templates, which is where a great deal of an Angular application
actually lives. We traded a flattering number for a **useful** one.

It goes down to the line. Open a template in the report and you get **per-line hit counts and
uncovered lines highlighted in red**, exactly as you would for TypeScript:

![A component template in the coverage report, with per-line hit counts and one uncovered line highlighted](https://blog.octoperf.com/img/blog/angular-20-to-22-migration/template-line-coverage.png "Line 6 of this template has never been rendered by a test")

That red line is a real gap in a real component: the markdown preview overlay of a table cell that
no test had ever opened.

A few other things came with Vitest browser mode, which runs tests in a real Chromium rather than
a simulated DOM:

- **failing tests save a screenshot** of the actual rendered DOM next to the spec file,
- `fakeAsync` and `tick()` are gone, replaced by Vitest's fake timers,
- `jasmine.createSpyObj` was replaced by a typed helper of our own, so the casting lives in one
  file instead of being spread over 2,700 specs.

One caveat, so nobody is surprised reading our configuration: **the coverage gate on the
application project is currently disabled**. Browser mode without test isolation produces
non-deterministic coverage numbers, and turning isolation on is far too slow at 2,082 test files.
We re-enable it when the upstream fix lands.

## Could We Have Done This Without Claude? { #migration-with-claude }

Not in three weeks. Probably not this year.

It is worth being precise about the division of labour, because "the AI did the migration" is
false and so is "the AI was a gimmick". A large share of the mechanical work was done by
**Angular's own schematics**, which we wrapped in make targets and had been using for years:
`@angular/core:control-flow` rewrote the templates, `@angular/core:inject` converted constructor
injection, `@angular/core:standalone` handled modules and bootstrapping,
`@angular/core:self-closing-tag` tidied the markup. Those are codemods. They are deterministic,
they are written by the framework team, and they are excellent.

What the codemods cannot do is anything requiring judgement:

- converting a **stateful component** to signals, which means deciding what is state, what is
  derived, and what is an effect,
- the **RxJS reentrancy audit**, where the fix depends on what the subscription was trying to
  achieve,
- rewriting **2,758 spec files** from Jasmine to Vitest, where every `jasmine.createSpyObj`,
  `fakeAsync`, marble test and `done()` callback needed a semantically equivalent replacement
  rather than a textual one,
- explaining why a test that passed under `zone.js` now fails, which is usually a genuine bug the
  zone was hiding.

That is the part [Claude](https://claude.com/product/claude-code) did, at a volume no human team
of our size could have typed. It is also the part where it was wrong often enough to matter, which
is why nothing moved forward without a human review of the pattern and a green suite.

The **safety net matters more than the migration** here, and it is the part we would tell anybody to
copy. Writing 7,000 lines of functional reference and 154 end-to-end tests is not clever work, it is
patient work, and it is precisely the work that gets postponed forever because no single sprint can
justify it. That is the shape of task where an LLM changes what is possible for a small team, far
more than any individual refactoring does.

Two artefacts made the difference between useful and unusable. The first is a **714-line
conventions file at the root of the repository**, listing what we do, what we never do, and why:
no `@Input()`, no `@ViewChild()`, no `NgZone`, no `fakeAsync`, `OnPush` written explicitly on every
component, Reactive Forms rather than the new Signal Forms. The second is **ESLint rules that
enforce the same thing mechanically**, so a drifting convention fails the build instead of
surviving a distracted review.

## Are We Becoming Dependent on LLMs for Our Migrations? { #llm-dependency }

![A human hand reaching towards a robotic hand](https://blog.octoperf.com/img/blog/angular-20-to-22-migration/human-hand-robot-hand.jpg "Leverage or dependency, the honest answer is that we are not sure yet")

We are not going to pretend to a tidy answer here, because we do not have one.

The argument that this is nothing new is genuinely strong. We already depended on tooling we did
not write to move between majors. `ng update` and the official schematics are exactly that, and
nobody calls a codemod a dependency problem. Our TypeScript compiler, our bundler and our linter
all rewrite our code. An LLM sits at the end of that same line, doing the transformations that
were previously too context-dependent to automate.

The argument that something did change is also strong. A codemod is **reproducible**. Run it
twice, get the same result, read its source if you want to know what it does. An LLM is none of
those things. It is a service, priced by a vendor, whose behaviour shifts between model versions.
If we could no longer use one tomorrow, the honest answer is that our next migration would take
considerably longer than this one did.

What we can control is **where the knowledge lives**, and that is the deliberate lesson of this
migration. It does not live in a chat log. It lives in the conventions file, in the lint rules, in
57 documents describing what every screen is supposed to do, and in 10,919 unit tests plus 154
end-to-end tests that fail when somebody gets it wrong. That is what a new engineer reads, and it is
also what the LLM reads before touching anything. **The output we care about is not the migrated
code, it is the artefacts that outlive the tool that wrote them.**

Is that enough? Ask us again after Angular 23.

## One Major a Year Is a Relief { #angular-annual-releases }

![An open yearly planner](https://blog.octoperf.com/img/blog/angular-20-to-22-migration/yearly-planner-calendar.jpg "A migration you can put in the calendar")

One piece of genuinely good news arrived with Angular 22, and it is not a feature. **Angular has
moved from a major release every six months to one every twelve months**, with four to six minor
releases in between. The support window is unchanged, at 12 months of active support followed by
12 months of LTS, so those 24 months now cover **two release cycles instead of four**.

For a team our size this changes the economics. A six-month cadence meant a migration was
effectively **permanent background work**: finish one, and the next is already in beta. A yearly
cadence makes it a **scheduled item**, something you plan, staff and finish. Combined with the
fact that the framework's defaults have caught up with what we consider good practice, which is
standalone components, signals, `OnPush` and no zones, we expect the next migration to be far less
dramatic than this one.

## Conclusion { #conclusion }

The scoreboard, measured rather than estimated: **5,221 files rewritten, `zone.js` deleted and a
polyfills bundle down to 0 bytes, the full test suite from 173 to 147 seconds and no longer
disconnecting halfway, and a coverage report that measures 63% more of our code because it finally
includes our templates**.

The lasting change is not in the version number. It is that the codebase now **describes its
state** instead of instructing the framework when to look at it. There are no `detectChanges()`
calls left to reason about, no `runOutsideAngular()` escape hatches, and the change detection
strategy is the same everywhere.

But the lesson we keep is the one about the order of operations, and it is the same whether your
migration is done by a codemod, an LLM or a person. **Before you migrate anything, work out which
half of the job is actually expensive.** For us it was never writing the new code, it was proving
the old behaviour survived. Fix that first and the strategy question answers itself: with a real
safety net, one big migration beats thirteen small ones, and Angular 23 gets to be boring. Boring
is the goal.

If you want to see what all this front-end work is actually in service of, take
[OctoPerf](https://octoperf.com/) for a spin, or read how we now drive it
[from an AI assistant](https://blog.octoperf.com/octoperf-mcp-server/).
