Going zoneless without a long-lived branch
Zoneless is the last step of a migration, not the first. What to fix before you flip the provider, and how to ship it in pieces.
Zoneless is the least interesting part of a zoneless migration. Turning it on is one provider. Everything that makes the migration succeed or fail happens before that line, and the teams that get into trouble are the ones that flip the flag first and then spend a quarter chasing views that stopped updating.
The framing that works: zoneless is not a feature you adopt, it is a property your application earns once its change detection is driven by state rather than by side effects. Get the state right and the flag is a formality.
Why the big-bang branch fails
The instinct is to branch, migrate everything, and merge. On any codebase with active feature work this fails for a reason that has nothing to do with Angular: the branch diverges faster than it converges. Six weeks in you are resolving conflicts in files you already migrated, against features written by people who did not know the rules changed.
Every step below ships independently to main. None of them require zoneless to be on. That is the point — if the migration is paused for a release, or for a quarter, the codebase is left in a coherent state rather than half-converted.
Step 1: find what actually depends on the zone
Zone.js patches async APIs and triggers change detection when they fire. Remove it and anything relying on that implicit trigger stops updating. The offenders are findable before you change anything:
# Timers that mutate state and rely on the zone to notice
rg -n "setTimeout|setInterval" src --type ts
# Explicit zone usage — each one is a decision to re-make
rg -n "NgZone|runOutsideAngular|ApplicationRef.tick" src --type ts
# Manual change detection — often a symptom, sometimes the fix
rg -n "detectChanges|markForCheck|ChangeDetectorRef" src --type ts
# Non-Angular async sources: these never had a zone guarantee worth trusting
rg -n "addEventListener|new Worker|WebSocket|IntersectionObserver" src --type tsSort the results into three piles: state mutations that should become signal writes, genuine outside-Angular work that should stay outside, and manual `detectChanges` calls that are papering over the first category. The third pile is usually the largest and the most informative — every one of them is a place where someone already noticed the zone was not doing what they expected.
Step 2: convert the triggers, not the components
The migration unit is not the component, it is the async source. A timer that writes to a signal is zoneless-safe regardless of which component reads it, which means you can convert data flows one at a time without touching the templates that consume them.
// Before: relies on the zone noticing the timer fired
export class SessionBannerComponent implements OnInit {
remaining = 0;
ngOnInit(): void {
setInterval(() => {
this.remaining = this.session.secondsLeft();
}, 1000);
}
}
// After: the write itself notifies. No zone required.
export class SessionBannerComponent {
readonly remaining = signal(0);
private readonly session = inject(SessionService);
constructor() {
const id = setInterval(() => {
this.remaining.set(this.session.secondsLeft());
}, 1000);
inject(DestroyRef).onDestroy(() => clearInterval(id));
}
}Step 3: fix the RxJS boundary
Observables do not notify change detection by themselves — the `async` pipe does, by calling `markForCheck`. Under zoneless the async pipe still works, so a wholesale RxJS rewrite is not required. What does break is the pattern of subscribing manually and assigning to a field, which was only ever working because the zone saw the HTTP call.
// Breaks under zoneless: nothing tells the view the field changed
export class CaseListComponent implements OnInit {
cases: Case[] = [];
ngOnInit(): void {
this.api.cases().subscribe((c) => (this.cases = c));
}
}
// Works: the signal write is the notification
export class CaseListComponent {
private readonly api = inject(CaseApi);
readonly cases = toSignal(this.api.cases(), { initialValue: [] as Case[] });
}Do not take this as licence to delete RxJS. Debouncing a search box, merging a websocket with a poll, cancelling an in-flight request on navigation — these are still stream problems and signals are a poor substitute. Convert the boundary, keep the streams.
Step 4: third-party libraries
This is where migrations actually stall, and it is worth auditing early because it can change the plan. Any library that mutates state from a non-Angular callback — chart libraries with animation loops, older date pickers, map SDKs, anything wrapping a jQuery-era widget — assumed the zone was watching.
The fix is a signal write inside the callback. The risk is the library you cannot patch, which is a reason to know about it in week one rather than week nine.
export class ChartHostComponent {
readonly selectedPoint = signal<Point | null>(null);
private init(el: HTMLElement): void {
thirdPartyChart(el, {
// Fires outside Angular. The signal write is what makes it visible.
onSelect: (point: Point) => this.selectedPoint.set(point),
});
}
}Step 5: flip the flag
Only now, and it is genuinely two lines. Angular v21 is zoneless by default; on v20 and earlier you opt in:
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
],
});Removing `zone.js` from the test polyfills matters as much as the build one. Left in place, your tests keep passing under a zone your production application no longer has, which is the worst of both worlds: a green suite that is no longer testing the thing you ship.
What this costs, honestly
On an application of moderate size with disciplined state management, this is days. On a large codebase with years of manual `detectChanges` calls and a few unpatchable libraries, it is weeks — but they are weeks of small merged pull requests rather than one terrifying merge.
The reason to do it is not the benchmark. It is that zoneless makes imprecise state expensive and precise state cheap, so it applies steady pressure in the direction you wanted the codebase to go anyway. Most of the value arrives during step two, before the flag is ever set.

