How would you plan moving a large zone.js-based Angular application to zoneless change detection without a risky big-bang cutover?
answer
- find hidden zone reliance first
- OnPush-compatible components as the stepping stone
- tests run zoneless before production
- exhaustive dev check catches stragglers
basics
~20 sMake components notify Angular first (signals, AsyncPipe, markForCheck, OnPush-compatible), remove NgZone observable dependencies, switch tests to zoneless, verify with exhaustive dev checks, then drop provideZoneChangeDetection and zone.js from polyfills, one app or shell at a time.
solid answer
~40 sI would treat it as making code *notify* correctly while zone.js is still present, and only then removing the zone. First inventory the risks: plain fields mutated in timers, third-party callbacks and `await` chains; subscriptions to `NgZone.onStable`/`onMicrotaskEmpty` and `isStable` guards; libraries that expect zone.js; SSR code relying on zone stability. Then move components to OnPush-compatible patterns, meaning state in signals or via `AsyncPipe`, `markForCheck()` where needed, and replace `onStable` waits with `afterNextRender`/`afterEveryRender`. Next run the test suite zoneless (`provideZonelessChangeDetection()` in `TestBed`, or remove `zone.js/testing`); TestBed throws when a component changes a bound value without a notification. In development, `provideCheckNoChangesConfig({exhaustive: true, interval})` surfaces bindings that changed silently. Then remove `provideZoneChangeDetection()` per deployable app behind a release you can roll back, add `PendingTasks` where SSR relied on zone stability, and finally remove `zone.js` from `polyfills`.
go deeper
Recall that zoneless apps need state changes to notify Angular, through signals, AsyncPipe or markForCheck, and that zone.js is removed from the polyfills.
Describe the concrete code changes: signals, AsyncPipe, markForCheck and render hooks in place of NgZone observables.
Lay out the phases, the tooling that proves readiness (zoneless tests, exhaustive dev checks) and a per-app cutover with rollback.
Justify whether to migrate at all with measured benefit, sequence teams and shared libraries, and own the tradeoff between thorough signal conversion and quick markForCheck patches.
## Framing the decision Since v21 a new Angular app is **zoneless by default**, but a large application that predates it usually still opts into zone.js through `provideZoneChangeDetection()` and loads `zone.js` in its polyfills. Zone-based scheduling keeps working and is not deprecated, so the question is not "must we" but "what does it buy, and how do we get there safely". The payoff is fewer needless checks, a smaller bundle, faster startup and cleaner stack traces. The risk is that code which worked only because zone.js noticed *every* async task will silently stop updating the UI. The central idea of a safe plan: **make the code zoneless-compatible while zone.js is still running**, so each change is safe to ship on its own, and flip the scheduler only when the evidence says nothing depends on the zone. ## Phase 1: inventory Search the codebase and dependencies for the patterns that break: - plain-field assignments inside `setTimeout`, `setInterval`, promise chains after `await`, WebSocket handlers and third-party library callbacks; - subscriptions to `NgZone.onStable`, `onMicrotaskEmpty`, `onUnstable`, and conditions on `NgZone.isStable`; - reactive-forms state read in templates without `AsyncPipe` or signals; - SSR code that relied on zone stability to wait for custom async work; - third-party component libraries, checked for zoneless support in their own documentation. ## Phase 2: make components notify The zoneless guide recommends **OnPush-compatible** components as the stepping stone, because an OnPush component already has to notify Angular through the same mechanisms zoneless depends on. 1. Move template state to **signals**, or deliver streams through `AsyncPipe`/`toSignal`. 2. Add `ChangeDetectorRef.markForCheck()` where legacy code must keep mutating fields. 3. Replace `onStable` waits with `afterNextRender` or `afterEveryRender`, or a DOM observer. 4. Keep `NgZone.run()` and `runOutsideAngular()`; they are harmless in zoneless apps. Each of these changes is correct under zone.js too, so it ships continuously without a flag. ## Phase 3: make the evidence visible | Tool | What it catches | |---|---| | Tests with `provideZonelessChangeDetection()` or without `zone.js/testing` | TestBed throws `ExpressionChangedAfterItHasBeenCheckedError` when a bound value changed without a notification | | `provideCheckNoChangesConfig({exhaustive: true, interval})` in dev builds | Periodic checks of every view that throw when a binding changed silently | | Grep-based lint rules for the Phase 1 patterns | New regressions during the migration | Tracking the count of failures per feature area gives the team a burn-down to steer by. ## Phase 4: cut over per deployable Flip each deployable application separately rather than the whole estate: 1. Remove `provideZoneChangeDetection()` so the v21+ default applies. 2. Add `PendingTasks` (or `pendingUntilEvent()`) where SSR must wait for work Angular does not track. 3. Keep zone.js in polyfills for one release, so rolling back is a one-line provider change. 4. Once production telemetry and error rates are clean, remove `zone.js` and `zone.js/testing` from `angular.json` and uninstall the package. ## Knowing the migration is finished The cutover is done when a few observable conditions hold, not when the last pull request merges: - no `provideZoneChangeDetection()` remains in any bootstrap or test configuration; - `zone.js` and `zone.js/testing` are gone from every `polyfills` entry, and the package is uninstalled; - the test suite passes zoneless and the exhaustive dev check reports no silent binding changes; - server-rendered pages still contain their data, which confirms that every async dependency is tracked. The lint rules from Phase 3 should stay after the migration, because new code can reintroduce the same patterns. ## Tradeoffs to own - **Library authors vs app teams.** A shared component library must keep working in apps that are still zone-based; the guide notes it cannot always use OnPush when it hosts user components created through `ViewContainerRef.createComponent`. - **Speed vs certainty.** Converting everything to signals first is thorough but slow; `markForCheck()` patches are quick but leave debt. - **When to stop.** A large, stable app with no performance complaints may reasonably stay on zone.js for now; the plan should state the measured benefit, such as bundle size, interaction latency or fewer change-detection cycles in the profiler, that justifies the cost.
- During an Angular zoneless migration, why ship OnPush-compatible changes before removing zone.js?Because each such change is also correct under zone.js, it can ship continuously and be verified in production without flipping anything. By the time the scheduler becomes zoneless, the code already notifies Angular through signals, AsyncPipe or markForCheck, so the cutover is a small, reversible provider change instead of a big-bang rewrite.
- How do you keep a rollback path when switching an Angular app to zoneless?Flip the scheduler by removing `provideZoneChangeDetection()` while leaving zone.js in the polyfills for a release. If production shows stale UI, re-adding the provider restores zone scheduling without a dependency change. Only remove zone.js from the build once the zoneless release has proven itself.
saying these in an interview costs you the question
- Remove zone.js from polyfills first and fix whatever breaks.
- Zoneless is impossible until every component is OnPush.
- Delete all NgZone.run() calls as the first migration step.
- A green existing test suite proves the app is zoneless-ready.
- SSR needs no changes because HttpClient covers all async work.