Removing zone.js takes about a minute. Earning the right to do it takes considerably longer.

Deleting zone.js from an Angular application is a one-line change, and it is the easiest part of the migration by a wide margin. Everything that makes it safe happens beforehand, and on a large codebase most of that work turns out to be in your tests rather than your components.

I am partway through this on a large Angular application. We have been upgrading one version at a time since around v16, when the first pieces of this started landing, and we are on v21 now. This is a field report from the middle of it rather than a victory lap, so it ends on the things we have not solved yet.

The part that surprised me most is that upgrading does not move you toward zoneless at all. If anything it moves you sideways. Let’s start there.

1. The last step is the easiest one

Here is the change everyone thinks of as “the migration”.

Listing 01The entire zoneless switchTypeScript
bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
  ],
});

Then you remove zone.js from your polyfills and uninstall the package. That is it. If you are on Angular 20.2 or later the API is stable and no longer carries the Experimental prefix it had while this was being developed.

The reason this is trivial is that it does not actually change anything about how Angular works. It changes who is responsible for saying that something happened. Zone.js was never a change detection mechanism, it was a scheduler that told Angular something asynchronous had occurred without saying what, and Angular responded by checking everything it could reach. Take it away and the framework still works exactly the same way. It just stops receiving those notifications, so anything that relied on them silently stops updating.

Which means the migration is not this line. The migration is making every part of your application capable of saying what changed, so that when the notifications stop, nothing depends on them. That is the work, and it is worth being honest with yourself about its size before you start.

2. What upgrading actually gives you, which is less than you think

Angular v22 made OnPush the default change detection strategy, and if you have only read the headline you would reasonably assume that upgrading gets you most of the way there for free.

It does not. When you take an existing application to v22, Angular runs an ng update migration with this description:

change-detection-eager: Adds ChangeDetectionStrategy.Eager to all components.

Read that carefully, because it is the opposite of what the headline implies. The migration walks your codebase and writes an explicit ChangeDetectionStrategy.Eager annotation onto every single component you have, so that your existing behaviour is preserved and nothing breaks on upgrade.

This is the correct decision by the Angular team. Silently flipping thousands of components to OnPush during a version bump would be reckless, and the migration is doing exactly what a good migration should. But it does mean the new default applies to components you write from now on, not to the ones you already have.

I find that reframing genuinely useful. A migration you can count is a migration you can plan, and it gives you a number that goes down. If you want the detail on what Eager and OnPush actually do to a view, I wrote that up separately in how Angular change detection actually works, and I am deliberately not repeating it here.

3. What the official schematics do and do not cover

There is a persistent piece of misinformation worth clearing up. Several published guides tell you to run this:

ng generate @angular/core:zoneless-migration

That schematic does not exist. It never has. If you run it you will get an error, and I mention it only because I have now seen it in enough articles that someone is going to lose an afternoon to it.

Here is what @angular/core actually ships, taken from its schematic collection:

Listing 02The real ng generate collection for @angular/core at v22.1.3Bash
ng generate @angular/core:signals                          # combines the signal migrations
ng generate @angular/core:signal-input-migration           # @Input()      -> input()
ng generate @angular/core:signal-queries-migration         # @ViewChild()  -> viewChild()
ng generate @angular/core:output-migration                 # @Output()     -> output()
ng generate @angular/core:standalone-migration
ng generate @angular/core:inject-migration                 # constructor DI -> inject()
ng generate @angular/core:control-flow-migration           # *ngIf         -> @if
ng generate @angular/core:common-to-standalone-migration
ng generate @angular/core:route-lazy-loading-migration
ng generate @angular/core:router-testing-module-migration
ng generate @angular/core:cleanup-unused-imports
ng generate @angular/core:self-closing-tags-migration
ng generate @angular/core:ngclass-to-class-migration
ng generate @angular/core:ngstyle-to-style-migration
ng generate @angular/core:service-migration                # @Injectable   -> @Service

Two things to note. The output one is output-migration, not signal-output-migration as it is often written. And signals is the one you actually want most of the time, because it runs the signal-related migrations together rather than making you sequence them yourself.

These are genuinely good, and they will do more mechanical work than you expect. What none of them do is the part that requires judgement: deciding what a component’s state actually is, which fields are derived rather than stored, and what should happen when an async callback lands. A schematic can turn an @Input() into an input(). It cannot tell you that three of your component’s fields are really one computed value.

So plan for the schematics to handle the syntax and for people to handle the semantics. On a large codebase the second is the larger number by a long way.

4. The order that actually works

The order matters more than any individual step, because each step should leave the application shippable. On a codebase of any size this migration will be interrupted, and a migration that cannot be paused safely is a migration that will eventually be abandoned halfway.

This is the sequence we settled on, adjusted for where Angular is now:

  1. Get to standalone components first. Not strictly required for zoneless, but every later schematic assumes it and the whole thing is smoother once modules are out of the way. If the schematic tells you there is nothing to be done, that is not success, and I have written about why it stops separately.
  2. Take one component off eager checking at a time. On v21 and earlier that means adding OnPush to it. On v22 it means deleting the Eager annotation the upgrade migration added. Either way it is the same decision, and either way you then make the component correct without a full check. Do not do this in bulk, because the whole value is that each one forces you to answer what marks this view.
  3. Move template state to signals. This is where the real behavioural change happens, and it is what makes step 2 sustainable rather than a slow accumulation of markForCheck() calls.
  4. Convert inputs and queries with the schematics. Cheap, mechanical, and best done after step 3 so you are not migrating fields you were about to delete anyway.
  5. Handle the async edges deliberately. Timers, subscriptions and anything crossing a framework boundary. This is judgement work and it does not parallelise well.
  6. Fix the tests. The one everyone underestimates, including me. It gets its own section below.
  7. Flip the switch. One line, as established.

The property I would protect above all others is that steps 1 through 5 deliver value while zone.js is still installed. Fewer unnecessary checks is a good outcome on its own, and it means the work is defensible to a product owner even if the final step slips to a later quarter. It usually will.

5. Where the real resistance is

Your own code is not the hard part. Your own code is tedious, but it is tractable, and the compiler and the schematics help you.

The hard part is anything that mutates state from its own callbacks, outside Angular’s knowledge. Charting libraries that write back to a bound property when the user clicks a series. Map components with their own event systems. Drag and drop implementations holding native listeners. Anything wrapping a web component. Anything with a postMessage handler or its own socket. All of these worked before because zone.js had patched the underlying browser API and Angular checked everything afterwards, and none of them will work once that stops.

The symptom is consistent and, once you have seen it a few times, unmistakable. The data is correct in the debugger and stale on the screen. There is no error, nothing throws, and the value is genuinely right in memory. Angular simply never heard about it.

The containment pattern is to stop trying to make the library cooperate and instead control the boundary yourself.

Listing 03Containing a third-party callback at the boundaryTypeScript
@Component({
  selector: 'app-usage-chart',
  template: `<p>Selected: {{ selected() ?? 'none' }}</p><div #host></div>`,
})
export class UsageChartComponent implements AfterViewInit {
  private readonly host = viewChild.required<ElementRef>('host');
  protected readonly selected = signal<string | null>(null);

  ngAfterViewInit() {
    createSomeChart(this.host().nativeElement, {
      // The library calls this from its own listener, outside anything Angular patched.
      onSelect: (series: string) => this.selected.set(series),
    });
  }
}

The callback still fires from wherever the library likes. The difference is that it now lands on a signal, and writing to a signal is something Angular does know about. You have not changed the library’s behaviour, you have chosen the point at which its behaviour enters your application.

My practical advice is to inventory these before you start rather than discovering them one bug report at a time. Search for every third-party component your templates render and treat each as a task with an owner, because the failure mode is silent and your test suite is unlikely to catch it.

6. What happens to your tests

This is the section I would have wanted to read before we began.

Every fakeAsync test in your codebase has to be rewritten, because fakeAsync is built on Zone.js and cannot function without it. It is not a deprecation or a soft warning. The helper throws:

zone-testing.js is needed for the fakeAsync() test helper but could not be found. Please make sure that your environment includes zone.js/testing

The same source file notes that the API requires Zone.js and cannot be used with the Vitest runner, which matters if you were also considering that move. So fakeAsync, tick and flush all go, and on a mature codebase that can be a very large number of tests written over many years by people who have since left.

There is a second, smaller thing in the same area. ChangeDetectorRef.checkNoChanges was removed in v22, having been deprecated since v17. If you have tests calling it, they need to use fixture.detectChanges() instead.

What replaces fakeAsync is mostly real async tests: await fixture.whenStable(), awaiting the actual promise, and letting the component under test behave the way it behaves in production. I have come to think this is better, because fakeAsync always let us assert on a version of time that our users never experienced. But “better” and “free” are different words, and I would not want anyone to start this migration believing the testing work is a rounding error. On a large application it is plausibly the biggest single line item.

7. Flipping the switch

When the preparation is genuinely done, the last step is the one from section 1: add provideZonelessChangeDetection(), remove the polyfill entry, uninstall the package.

Two things are worth knowing about the state of the ecosystem around this. Zoneless has been stable since v20.2, so this is no longer an experimental API and the old provideExperimentalZonelessChangeDetection name is gone. And from v21, new applications are zoneless by default, which means the direction of travel is settled. Existing applications that still need Zone.js now opt back in explicitly with provideZoneChangeDetection(), and the v21 upgrade adds that for you if you need it.

That last detail is a nice signal of intent. Zone.js is no longer the default that you migrate away from. It is now the exception that you opt into.

Immediately after flipping, the things to watch are the ones that never had a test: anything triggered by a browser event you did not write, anything in a third-party wrapper, and any component whose state changes only in response to something outside Angular. Those are also, not coincidentally, the places where Eager is most tempting as a quick fix. It is worth resisting, because reaching for Eager hides a missing mark rather than fixing it, and you will meet the same bug later somewhere that Eager is not available.

8. Where we actually are, and what is still open

We are not finished, and I want to be specific about that rather than ending on a summary that implies otherwise.

The mechanical work has gone better than I expected. The schematics are good, and taking components off eager checking one at a time is a genuinely pleasant way to work, because the scope of each change is small and the feedback is immediate. We are still on v21, so for us that has meant adding OnPush rather than deleting annotations a migration added. When we reach v22 the direction reverses and the remaining work becomes countable, which I am looking forward to more than I expected to be.

The things that remain difficult are the ones I would flag to anyone considering this:

  • The test rewrite is the long pole. It is unglamorous, it is hard to justify against feature work, and it is the thing standing between a mostly-migrated application and an actually-migrated one.
  • Third-party components remain the least predictable part. Each one is its own small investigation, and the answer is sometimes to replace the library rather than wrap it.
  • Partially migrated is a real state you live in for a long time. Components that are signal-driven sit next to components that are not, and both have to keep working. That is fine, but it means you carry two mental models at once for longer than you would like.

If you are weighing this up, my honest summary is that the framework side is in good shape and the hard parts are all in your own history. The migration is not really about Angular. It is about how much code you have that assumed someone else would notice when things changed.

Sources and further reading