Skip to content
Angular 20 to 22 Migration: Why We Stopped Going Incremental

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?

Elevate your Load Testing!
Request a Demo

Table of Contents

Where We Started: Angular 20 and 5,000 Files

A building wrapped in scaffolding

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

The end-to-end test pull request: 142 files, 11,646 additions

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.

The migration pull request: 182,379 additions and 182,019 deletions

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

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:

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

The Vitest coverage report, with HTML templates listed alongside their components

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

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?

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 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?

A human hand reaching towards a robotic hand

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

An open yearly planner

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.

Want to become a super load tester?
Request a Demo