Plan a Safe Multi‑Phase Migration from zone.js to Signals + SignalStore in Angular 20+

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=3500

Bootstrap: 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

Related Resources

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.

Hire Matthew – Remote Angular Expert, Available Now Stabilize Your Angular Codebase with gitPlumbers

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
NG Wave Component Library

Related resources