
Plan a Safe Multi‑Phase Migration from zone.js to Signals + SignalStore in Angular 20+
A proven, phased blueprint to go zoneless with Signals + SignalStore—using flags, adapters, and CI guardrails so your UX never blinks.
You don’t flip a switch—you ship a series of safe toggles with telemetry watching your back.Back to all posts
I’ve shipped zoneless Angular 20+ in dashboards, kiosk software, and AI-backed products without the dreaded jitter or broken spinners. The trick is planning. Below is the step-by-step blueprint I use on real enterprise codebases—measurable, reversible, and boring in the best way.
The Jitter Test: A Real Scene from the Front Lines
What usually breaks first
On a telecom analytics dashboard I maintained, the first zoneless canary made charts jitter when WebSocket bursts hit. Another client’s kiosk lost focus when switching input devices. Both were fixable—but only because we shipped in phases with flags and telemetry, not a Friday cutover.
Sticky loaders that never resolve
Focus traps in dialogs
Animations that re-run or freeze
Live charts that double-render
Context for 2025 roadmaps
As enterprises budget for 2025, I’m seeing more teams ask an Angular consultant to plan the move. Signals + SignalStore simplify mental models, but the migration must respect existing UX and integrations.
Angular 20’s Signals and SignalStore are stable for production
Zoneless CD removes a global patch cost and unlocks predictable renders
Why Angular Apps Break During Zoneless Signals Migration
Hidden zone coupling
Zone hides timing bugs. When you remove it, renders become explicit. Anything depending on “the framework will re-run me later” shows up as missed updates or extra ticks.
Code relying on microtask timing (setTimeout/Promise.resolve)
Components calling markForCheck everywhere
3rd‑party libs touching NgZone.onStable or patching events
Async pipe feeding impure transforms
Observable-heavy state without clear ownership
Signals encourage uni-directional state with ownership in stores. If your components orchestrate streams directly, the risk of missed renders increases when zone.js is gone.
Nested subscriptions in components
Manual CD triggers coupled to subscription lifecycles
Overlay, focus, and a11y edge cases
PrimeNG/Material are increasingly Signals-friendly, but you still need regression tests around modals, menus, and overlays.
Dialog focus traps and portal systems
Keyboard handlers that assume immediate re-render
A Multi‑Phase Plan: From zone.js to Signals + SignalStore
Phase 0: Baseline, inventory, and guardrails
Before touching code, measure. Record INP, LCP, First Input Delay, and hydration warnings on SSR builds. Tag sessions so you can compare zone vs zoneless cohorts later.
Angular DevTools flame charts & change detection runs
Lighthouse CI budgets in Nx/GitHub Actions
Error taxonomy in telemetry (GA4/Firebase)
A/B cohorts tagged for canaries
Phase 1: Signal islands while zone.js remains
This reduces risk and makes rendering deterministic without changing global CD. Prefer SignalStore so ownership is centralized and components get plain signals.
Introduce SignalStore for 1-2 critical slices
Wrap RxJS streams with toSignal/fromObservable
Keep async pipe for non-hot paths
Phase 2: Component refactors
Refactor janky components first—charts, schedulers, and lists. Measure rerender counts with Angular DevTools to verify fewer ticks.
Replace heavy async pipes with signals in hot components
Move orchestrations into store methods
Add effects for side-effects; keep components pure
Phase 3: Zoneless canary behind a flag
Ship to 5-10% of traffic. Watch overlay/focus behavior and live updates. Roll back instantly by flipping the remote flag if anything looks off.
Enable ngZone:'noop' only for flagged cohorts
Prepare shims for specific libraries if needed
Phase 4: Cleanup and rollout
When metrics hold, remove shims and legacy code. Lock in budgets so future regressions fail fast in CI.
Delete markForCheck spam and setTimeout hacks
Finalize SSR hydration checks
Scale cohort to 50% → 100%
Implementation Deep Dive: Flags, Adapters, and Store Patterns
// main.ts (Angular 20+)
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideZoneChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
import { injectFlags } from './app/flags';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(),
// Keep zone.js during early phases; enable coalescing now
provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }),
],
// Later, for canary cohorts only:
// ngZone: injectFlags().zonelessCanary() ? 'noop' : undefined,
});// flags.ts – Firebase Remote Config or env-backed feature flags
import { signal, computed, inject } from '@angular/core';
import { RemoteConfig, getBoolean } from './remote-config';
const raw = signal({ zonelessCanary: false, enableSignals: true });
export function provideFlags() {
const rc = inject(RemoteConfig);
raw.update(v => ({
...v,
zonelessCanary: getBoolean(rc, 'zoneless_canary'),
enableSignals: getBoolean(rc, 'enable_signals')
}));
return { raw };
}
export function injectFlags() {
const state = provideFlags();
return {
zonelessCanary: () => computed(() => state.raw().zonelessCanary)(),
enableSignals: () => computed(() => state.raw().enableSignals)()
};
}// users.store.ts – SignalStore slice with RxJS interop
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { switchMap, map } from 'rxjs/operators';
import { UsersApi, User } from './users.api';
interface UsersState {
byId: Record<string, User>;
loading: boolean;
error?: string | null;
}
export const UsersStore = signalStore(
withState<UsersState>({ byId: {}, loading: false, error: null }),
withComputed((state) => ({
list: () => Object.values(state.byId()),
count: () => Object.keys(state.byId()).length
})),
withMethods((store) => {
const api = inject(UsersApi);
return {
load() {
patchState(store, { loading: true, error: null });
const stream$ = api.list$().pipe(map(users => Object.fromEntries(users.map(u => [u.id, u]))));
const usersSig = toSignal(stream$, { initialValue: {} as Record<string, User> });
// When usersSig changes, patch state once (zoneless-friendly, no manual markForCheck)
patchState(store, { byId: usersSig(), loading: false });
}
};
})
);<!-- users.component.html – Signals in the view -->
<section *ngIf="store.count() as count">
<p>{{ count }} users</p>
<ul>
<li *ngFor="let u of store.list(); trackBy: trackById">{{ u.name }}</li>
</ul>
</section># .github/workflows/ci.yml – Nx + Lighthouse budgets + tests
name: ci
on: [push, pull_request]
jobs:
build_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx nx run-many -t lint,test,build --parallel=3
- name: Lighthouse CI (SSR build)
run: |
npx lhci autorun --collect.url=https://preview.example.com \
--assert.assertions."performance".minScore=0.9 \
--assert.assertions."interactive".maxNumericValue=3500Bootstrap: prepare for canary runs
You can run with zone.js enabled while adopting Signals, then flip to zoneless for canaries.
Bootstrap snippets
Fixing Real Issues: Overlays, Focus, and Streams
import { afterNextRender } from '@angular/core';
openDialog() {
this.dialog.open(FormDialog);
afterNextRender(() => {
const el = document.querySelector('[autofocus]') as HTMLElement | null;
el?.focus();
});
}// WebSocket toSignal with retry that won’t jitter charts
import { toSignal } from '@angular/core/rxjs-interop';
import { webSocket } from 'rxjs/webSocket';
import { retryBackoff } from 'backoff-rxjs';
const ws$ = webSocket<{ type: string; payload: unknown }>("wss://telemetry.example.com").pipe(
retryBackoff({ initialInterval: 500, maxInterval: 10_000, maxRetries: 8 })
);
export const events = toSignal(ws$, { initialValue: { type: 'init', payload: null } });Overlays and focus traps
When dialogs misbehave without zone.js, schedule DOM reads/writes explicitly:
Audit modal open/close with E2E affordances
Use afterNextRender for measured DOM work
Snippet: schedule work explicitly
How an Angular Consultant Approaches Signals Migration
Real engagements
I prioritize high-traffic screens first and wire telemetry to prove fewer change detection runs and better INP. On the airline kiosk, zoneless + SignalStore stabilized device state across barcode scanners and receipt printers while keeping flows offline-tolerant.
Telecom advertising analytics dashboard
Airport kiosk with Docker-simulated peripherals
Employee tracking/payroll for a global studio
Proof you can defend
I won’t ask you to trust vibes. We ship guardrails and show the numbers. If you need to hire an Angular developer with Fortune 100 experience, I’m available for remote engagements.
Before/after DevTools render counts
Lighthouse CI deltas in PR comments
99.98% uptime (gitPlumbers) while modernizing
Key takeaways
- You don’t flip a switch—ship zoneless in phases with feature flags and canary cohorts.
- Start by measuring UX stability, then build SignalStore-backed state while zone.js still runs.
- Use adapters (toSignal/fromObservable) to bring RxJS and NgRx along safely.
- Run canary builds with ngZone:'noop' behind a remote flag; watch UX metrics and telemetry.
- Lock in wins with CI budgets, Angular DevTools baselines, and automatic rollback rules.
Implementation checklist
- Inventory zone-coupled code (ChangeDetectorRef, markForCheck, async pipe misuse, third‑party libs).
- Instrument metrics: TTI, INP, hydration warnings, error taxonomies, WebSocket drop rate.
- Introduce SignalStore for 1-2 critical slices while keeping zone.js enabled.
- Wrap streams with toSignal/fromObservable; replace async pipe for hot paths.
- Add remote flags (Firebase Remote Config) to gate Signal paths and zoneless canary runs.
- Run ngZone:'noop' canaries for 5-10% of traffic; monitor Lighthouse CI, GA4/Perf, and logs.
- Fix regressions (focus traps, overlays, schedulers); re-run accessibility and E2E suites.
- Roll out to 50% → 100%; remove zone-era shims and cleanup ChangeDetectorRef churn.
Questions we hear from teams
- How long does a zone.js → Signals + SignalStore migration take?
- Small apps: 2–4 weeks. Enterprise dashboards: 4–8 weeks with phased rollouts and canary cohorts. We start measuring in week 1, ship SignalStore islands in week 2–3, and run zoneless canaries once hot paths are stable.
- Do we need to replace NgRx to use Signals?
- No. Keep NgRx where it works and adopt SignalStore incrementally. Use toSignal/fromObservable for interop. Replace only the slices that benefit from signals, then evaluate the rest after metrics improve.
- Will third‑party components break without zone.js?
- Sometimes overlays, focus traps, or animation triggers need explicit scheduling. We fix with small adapters and regression tests. PrimeNG and Angular Material work well in zoneless mode with modern APIs.
- What does a typical Angular engagement look like?
- Discovery call in 48 hours, assessment in 1 week, then phased delivery. I set flags, telemetry, and CI guardrails. We ship SignalStore islands first, run zoneless canaries, and roll out when metrics hold. Limited availability each quarter.
- How much does it cost to hire an Angular consultant for this migration?
- It varies by scope and risk. Most teams budget a few weeks of senior time plus CI and testing. I provide a fixed-scope assessment and an execution plan—reach out to discuss your Angular roadmap and constraints.
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