Life without Zone.js
For most of Angular's life, Zone.js has been the reason your templates update without you asking. It patches the browser — timers, promises, events, XHR — and after practically any patched async operation happens, it tells Angular "something may have changed, go check". Convenient, invisible, and paid for on every async operation in your app, including the ones that change nothing on screen.
Zoneless Angular removes the patch. Change detection stops being "after everything, check everything" and becomes "when something I can see changed" — a signal you wrote to, an event handler in a template, an async pipe receiving a value. Start a new app today and you get this for free: zoneless has been the default since Angular v21. On v20 you turn it on yourself with provideZonelessChangeDetection() at bootstrap, stable since 20.2. The interesting case, either way, is the app you already have.
What actually breaks
The honest list, from migrations I've been close to:
Mutations nobody announces. The classic: a setTimeout or a third-party callback assigns to a plain field, and the template used to update because Zone.js told Angular to look. Without the zone, nothing tells anyone anything. The value changes; the screen doesn't.
Components that only worked by accident. Older components — the ones still running the eager, check-everything strategy that used to be Angular's default — got re-checked constantly, which hid the ones that never signaled their own changes. (OnPush is the default since v22, so this mostly hits code written before that changed.) Zoneless is unforgiving about the difference between "worked" and "happened to work".
The onStable family, not NgZone.run. NgZone.run and runOutsideAngular are explicitly fine under zoneless — Angular's own docs warn that removing them can regress performance for libraries used inside applications that still rely on ZoneJS, so leave them alone. What actually goes silent is onMicrotaskEmpty, onUnstable, and onStable: none of the three ever emit once zoneless is on. isStable isn't an observable at all, just a getter — it stays permanently true. Code that waits on any of them hangs or silently stops branching the way it used to. Swap it for afterNextRender, afterEveryRender, or a plain MutationObserver.
Reactive forms. setValue, patchValue, FormArray.push and friends update the form model and fire form observables, but none of that schedules change detection under zoneless. A template reading form state can go stale exactly like the plain-field mutations above — wire it through markForCheck(), or reflect the value into a signal the template actually reads.
Zone-dependent tests. fakeAsync requires Zone.js. Under Karma/Jasmine it keeps working as long as zone.js/testing stays in the test polyfills — a legitimate interim step. Vitest, the CLI's default runner for new projects now, doesn't support it out of the box: its own API page lists Vitest as unsupported outright, though the migration guide documents zone.js/plugins/vitest-patch as an interim bridge that keeps old fakeAsync tests running there too — Angular's own docs disagree with themselves on this one. Either way it's a bridge, not the destination — the recommended target is native async/await with Vitest's own fake timers. New tests should be written that way from the start.
The order that works
The mistake is flipping the provider first and debugging a broken app backwards. The order that keeps you green at every step:
1. OnPush almost everywhere, first. Move components to ChangeDetectionStrategy.OnPush while Zone.js is still there — skip the rare library component that hosts user content through ViewContainerRef.createComponent, if what it hosts still relies on zone-driven refresh; going OnPush there breaks the hosted content. The zone keeps the rest of the app running while each conversion surfaces its own breakage. This step finds the bugs cheaply. 2. State into signals. Replace announced-by-nobody mutations with signal writes. Each converted component stops needing the zone at all — and, depending on how much it was re-rendering before, can get cheaper to check today, before the flip. 3. Retire the onStable family — swap onMicrotaskEmpty, onUnstable, onStable, and isStable checks for afterNextRender/afterEveryRender or a DOM observer, call site by call site. Leave NgZone.run/runOutsideAngular alone; they're still doing useful work. 4. Flip the provider, delete the polyfill. By now the flip is an anticlimax — which is exactly what you want it to be. Grep for and delete any leftover provideZoneChangeDetection override first, or it'll silently win over the default and the flip won't take. Remove zone.js and zone.js/testing from both the build and test polyfills (angular.json or polyfills.ts), then npm uninstall zone.js, and take the bundle and runtime win. That last removal flips TestBed to zoneless too — it throws ExpressionChangedAfterItHasBeenCheckedError on any suite still relying on an unconverted detectChanges() mutation, so rehearse it early by adding provideZonelessChangeDetection() to TestBed before you actually pull zone.js/testing, and lean on provideCheckNoChangesConfig({exhaustive: true}) to catch stragglers. Running SSR? Angular used ZoneJS to know when the app was "stable" enough to serialize — without it, wrap any async work that must finish first in PendingTasks, or the page can serialize too early.
Steps 1 and 2 are worth doing even if you never flip: OnPush plus signals is just better Angular. Zoneless is the reward at the end, not the leap at the start.