
Plan a Multi‑Phase Migration from zone.js to Signals + SignalStore in Angular 20—Without Breaking UX
A pragmatic, metrics‑driven path to go zoneless in Angular 20+: phase gates, SignalStore, adapters for legacy code, and CI guardrails that keep production calm.
“Don’t flip zoneless first. Teach your app to speak Signals, then remove zone.js like a cast after the bone has healed.”Back to all posts
I’ve broken production so you don’t have to. The first time I took a large Angular dashboard zoneless, a PrimeNG table stopped updating after a WebSocket burst. The root cause wasn’t Angular—it was our own NgZone/detectChanges band‑aids from years of patching. Here’s the plan I use now on Fortune 100 apps to move from zone.js change detection to Signals + SignalStore without jitter, regressions, or late‑night rollbacks.
The dashboard stops jittering, then freezes: the realities of going zoneless
The scene
It’s Monday. Your real‑time analytics board (telecom ad spend, 30k events/min) jitters less after early Signals refactors. Product smiles. Then you flip zoneless in a staging build—some widgets don’t update. Why? Imperative change‑detection hacks and third‑party assumptions about zone.js.
2025 context
As teams plan 2025 Angular roadmaps, zoneless + Signals is the biggest maintainability and performance unlock I’m seeing across dashboards, kiosks, and multi‑tenant portals. But you must phase it.
Angular 20+ makes zoneless stable and first‑class.
Hiring cycles pick up in Q1—leadership asks for hard numbers.
Why Angular apps break during zone.js → Signals migration
Imperative patches
Those work under zone.js because the microtask queue frequently nudges views. Zoneless removes that crutch; now only signals advance the UI.
ChangeDetectorRef.detectChanges sprinkled in services/components
NgZone.run used as a catch‑all for async callbacks
Third‑party assumptions
Libraries like PrimeNG are largely fine; the issues are almost always our in‑house wrappers that mutated inputs instead of emitting signals.
Legacy wrappers around Datepickers/Charts that call markForCheck
Direct mutation of Input objects (without a signal boundary)
Mixed async sources
Zoneless expects side effects to be modeled as effects and state changes to come through signals/SignalStore. When arrays are mutated in place, computed signals never re‑run.
RxJS streams not bridged into signals
WebSocket burst handlers mutating arrays in place
Plan a multi‑phase zone.js → Signals + SignalStore migration
Here’s how I wire the flag and the providers.
Phase 0: Instrument and freeze the blast radius
Before touching code, measure. I log DevTools cycles while replaying telemetry via a script, then pin thresholds in CI.
Baseline Angular DevTools change‑detection cycles on key routes
Capture Core Web Vitals (LCP/INP/CLS) with GA4 or Firebase Analytics
Add feature flag for zoneless builds
Phase 1: Signals under zone.js (no UX risk)
This builds the muscle without changing global detection. Most apps see 15–30% fewer view updates right here.
Convert high‑churn components to signal inputs/computed/effects
Bridge RxJS streams with toSignal
Stop mutating arrays/objects in place—use immutable updates
Phase 2: Introduce SignalStore facades
SignalStore gives you a single reactive surface for the UI to depend on. You can still feed it from NgRx or services during the transition.
Create store per domain (user, devices, analytics)
Expose readonly computed selectors + method updaters
Keep existing NgRx/services as the backing data source temporarily
Phase 3: Remove imperative patches
If a view depends on something, make it a signal. If something causes a side effect (e.g., analytics log), make it an effect.
Delete detectChanges/markForCheck scattered calls
Replace NgZone.run with signal updates and effects
Phase 4: Staging canary with zoneless
If a widget stalls, you’ll find an un‑signaled state mutation or a wrapper that assumed zones. Fix there; do not revert the approach.
Enable provideZonelessChangeDetection in staging build only
Run Cypress e2e + Lighthouse; diff DevTools cycles against baseline
Phase 5: Progressive production rollout
We’ve used the same pattern for airport kiosk apps and telecom dashboards—no after‑hours heroics required.
Firebase Hosting preview channels for QA sign‑off
1–5% canary, watch error budgets and INP
Ramp to 100% once stable
Phase 6: Retire zone.js + document patterns
Your future hires will thank you. And yes, recruiters love seeing clear Signals docs in repos.
Remove zone.js polyfill and clean config
Add ADRs and docs for Signals/SignalStore conventions
Bootstrap and config snippets you can drop in today
main.ts with zoneless flag
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { environment } from './environments/environment';
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
provideRouter(routes),
...(environment.enableZoneless ? [provideZonelessChangeDetection()] : [])
]
});Polyfills cleanup when going live
// polyfills.ts
// Remove: import 'zone.js'; // ❌ no longer needed when zonelessCI guardrail for a staged flip
# .github/workflows/e2e.yml
name: e2e-zoneless
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run build -- --configuration=staging --define=enableZoneless=true
- run: npx cypress run --config-file cypress.zoneless.config.ts
- run: npx lighthouse http://localhost:4200 --budget-path=./budgets.jsonSignalStore facade pattern over legacy state
This facade lets you keep old NgRx slices alive while the UI migrates. Later, you can retire reducers by moving their logic into store methods.
Store definition
// devices.store.ts
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { DevicesApi } from './devices.api';
interface DevicesState {
list: ReadonlyArray<Device>;
selectedId: string | null;
loading: boolean;
}
const initialState: DevicesState = { list: [], selectedId: null, loading: false };
export const DevicesStore = signalStore(
withState(initialState),
withMethods((store) => {
const api = inject(DevicesApi);
const load = async () => {
patchState(store, { loading: true });
const data = await api.fetchDevices();
patchState(store, { list: data, loading: false });
};
const select = (id: string | null) => patchState(store, { selectedId: id });
return { load, select };
})
);
// selectors (signals)
export const devices = (s: DevicesState) => s.list;
export const selected = (s: DevicesState) =>
computed(() => s.list.find(d => d.id === s.selectedId) ?? null);Component usage with Signals
@Component({
selector: 'ux-device-panel',
template: `
<p-table [value]="store.list()" (onRowSelect)="store.select($event.data.id)"></p-table>
<ux-device-details [device]="selected()" />
`
})
export class DevicePanel {
store = inject(DevicesStore);
readonly selected = selected(this.store.state());
ngOnInit() { this.store.load(); }
}Bridging RxJS feeds
// If you have a WebSocket stream
import { toSignal } from '@angular/core/rxjs';
readonly deviceEvents = toSignal(this.socket.events$, { initialValue: null });
// React via effect (no NgZone.run required)
effect(() => {
const evt = this.deviceEvents();
if (!evt) return;
this.store.applyEvent(evt); // method updates signals under the hood
});Example: migrating a PrimeNG DataTable filter to Signals
Under zoneless, this continues to work—no change detection calls, no NgZone. Angular DevTools will show far fewer view updates during fast typing.
Before (zone.js + mutable state)
filterText = '';
rows: Order[] = [];
applyFilter(txt: string) {
this.filterText = txt;
this.cd.detectChanges(); // ❌ imperative
}After (Signals + computed)
import { input, signal, computed, effect } from '@angular/core';
readonly filter = signal('');
readonly rows = signal<Order[]>([]);
readonly filtered = computed(() => {
const f = this.filter().toLowerCase();
return this.rows().filter(r => r.customer.toLowerCase().includes(f));
});
// template
// <input pInputText (input)="filter.set($any($event.target).value)"/>
// <p-table [value]="filtered()"></p-table>
// optional analytics without zone
effect(() => {
this.analytics.track('orders_filter', { q: this.filter() });
});How an Angular consultant approaches Signals migration
If you need to hire an Angular developer or engage a senior Angular expert to lead this work, I can run the plan end‑to‑end with your team—remote, hands‑on, and metrics‑driven.
Discovery (week 1)
I ship a written assessment with a prioritized migration map and risk register.
Audit high‑churn components via DevTools
Map NgZone/detectChanges hotspots
Baseline Web Vitals in CI
Implementation (weeks 2–6)
I keep feature delivery moving; no feature freeze required.
Convert components to Signals
Introduce SignalStore facades
Fix adapters around tables/charts/forms
Zoneless canary in staging → production
Outcomes I target
On a telecom analytics app, typed WebSockets + SignalStore cut dropped frames to zero and held 60fps during peak bursts.
−40–70% change‑detection cycles on key routes
Stable INP even under WebSocket bursts
Zero user-visible regressions during rollout
When to Hire an Angular Developer for Legacy Rescue
Signals that you need help
This is classic pre‑zoneless pain. Bringing in an Angular contractor for a focused 2–4 week rescue pays for itself in stability alone.
Frequent detectChanges/Zones patches in code reviews
Flaky tables/forms after async events
Multiple state sources with unclear ownership
What you’ll get
You keep shipping features while the foundation gets stronger.
A phased plan with roll‑back levers
SignalStore facades for critical domains
CI guardrails and documentation
Takeaways + what to instrument next
If you want a second set of eyes on your roadmap—or need an Angular consultant to lead the migration—let’s talk. I’m currently accepting 1–2 projects per quarter.
Measure, migrate, verify, then flip
You don’t need heroics—just a disciplined plan.
Instrument before code changes
Migrate components + state first
Kill imperative patches
Canary zoneless with guardrails
Next steps to instrument
I keep these dashboards visible to PMs so we make business‑visible progress every sprint.
Angular DevTools profiles saved per route
GA4/Firebase custom metrics for INP under interaction types
Feature flags tied to release channels
Key takeaways
- You don’t flip a global zoneless switch first—you earn it by migrating components and state to Signals incrementally under zone.js.
- Phase‑gate the rollout: instrument first, then convert component state, add SignalStore facades, canary zoneless in staging, then retire NgZone hacks.
- Adapters are your friend: toSignal for RxJS, SignalStore for domain state, thin wrappers for third‑party components that assumed zone.js.
- Guard the UX with metrics: Angular DevTools cycles, Core Web Vitals, and visual regression in CI (Storybook/Chromatic optional) before and after each phase.
- Roll out with feature flags and Firebase Hosting previews; if canary fails, revert by config—no code fork required.
Implementation checklist
- Instrument DevTools + Lighthouse baselines before any migration.
- Convert high‑churn components to Signals (inputs/computed/effects) under zone.js.
- Introduce SignalStore facades over existing services/NgRx slices.
- Kill brittle NgZone/detectChanges hacks and replace with signal‑driven flows.
- Run zoneless in staging behind a build flag; execute e2e and smoke tests.
- Canary release to <10% traffic; monitor INP/LCP and error budgets.
- Remove zone.js, clean polyfills, and document new patterns in ADRs.
Questions we hear from teams
- How long does a zone.js → Signals migration take?
- For a mid‑size dashboard, expect 2–3 weeks to instrument and convert critical components, another 1–2 weeks to add SignalStore facades, and 1 week for zoneless canary and rollout. Larger multi‑tenant apps run 6–8 weeks with parallel feature delivery.
- Will PrimeNG or charts break without zone.js?
- PrimeNG generally works fine. Issues arise in custom wrappers that mutate inputs or rely on detectChanges. Replace those with Signals and computed state. For charts (Highcharts/D3), update via signal setters instead of imperative redraw calls.
- Do we need to rewrite NgRx to use SignalStore?
- No. Introduce SignalStore as a facade and feed it from existing selectors/effects. Move logic incrementally into store methods. You can retire NgRx slices gradually without a risky flag day.
- What does an Angular consultant do on this engagement?
- I map hotspots, set up guardrails, convert high‑churn components to Signals, introduce SignalStore, and supervise the zoneless rollout. You get a phased plan, code reviews, CI checks, and documentation your team can sustain.
- How much does it cost to hire an Angular developer for this?
- Typical rescue/migration engagements run 2–8 weeks. I offer fixed‑scope assessments and weekly rates for implementation. Discovery call within 48 hours; written plan within 5 business days after access.
Ready to level up your Angular experience?
Let AngularUX review your Signals roadmap, design system, or SSR deployment plan.
NG Wave
Angular Component Library
A comprehensive collection of 110+ animated, interactive, and customizable Angular components. Converted from React Bits with full feature parity, built with Angular Signals, GSAP animations, and Three.js for stunning visual effects.
Explore Components