Angular 20 to 22 Migration: Why We Stopped Going Incremental
The OctoPerf 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.
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
- First Strategy: One Component at a Time
- The Realisation: Our Bottleneck Was Validation
- Writing the Safety Net Before the Migration
- Then One Giant Pull Request
- The Actual Work
- Going Zoneless: Where the Performance Came From
- Tests: Karma and Jasmine Out, Vitest In
- Could We Have Done This Without Claude?
- Are We Becoming Dependent on LLMs for Our Migrations?
- One Major a Year Is a Relief
- Conclusion
Where We Started: Angular 20 and 5,000 Files¶

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¶
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¶
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¶
We had a Playwright 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

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¶
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.

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.tsfiles, 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¶
Signals, in Six Phases¶
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¶
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¶
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 a while ago, and about
avoiding call expressions in templates.
Dropping @angular/animations for Native CSS¶
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¶
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¶
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 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:
OnPushis now the default in Angular 22 for any component that does not setchangeDetectionexplicitly, so the framework's default and our convention finally agree,NgZoneandrunOutsideAngular()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 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¶
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.

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:

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,
fakeAsyncandtick()are gone, replaced by Vitest's fake timers,jasmine.createSpyObjwas 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?¶
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 anddone()callback needed a semantically equivalent replacement rather than a textual one, - explaining why a test that passed under
zone.jsnow fails, which is usually a genuine bug the zone was hiding.
That is the part Claude 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?¶

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¶

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¶
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 for a spin, or read how we now drive it from an AI assistant.