Every Ionic Angular app we inherit still boots with zone.js. That was the
only option for years, and it is why so many Ionic lists feel sluggish on
mid-range Android: every touch event, every setTimeout, every XHR
completion triggers a whole-application change detection pass, and a
*ngFor of 500 ion-items gets re-checked each time.
Modern Angular gives you a way out. Signals make state reactive at the value
level, OnPush plus signals removes most of the guesswork, and zoneless
change detection drops zone.js entirely — smaller bundle, fewer wasted
render passes, and stack traces that are actually readable. Ionic's Angular
components are standalone and signal-friendly, so you can do this
incrementally on a real app.
This tutorial migrates a typical Ionic Angular page to signals, then flips the app to zoneless and fixes the things that break.
0. Where you need to be first
- Standalone components and
bootstrapApplication(noNgModuleapp root). If you are still onIonicModule.forRoot(), do that migration first — see Upgrading Ionic 4/5 Angular apps. - Angular's own schematics do most of the mechanical work:
ng generate @angular/core:standalone # run all three passes, commit each
ng generate @angular/core:control-flow # *ngIf/*ngFor -> @if/@for
ng generate @angular/core:signals # @Input/@Output -> input()/output()
Commit after each schematic and run the app. These are safe, reviewable diffs; the interesting work starts after them.
1. Signals instead of a BehaviorSubject soup
The pattern we find in most Ionic services: a BehaviorSubject per field, a
handful of combineLatest, and templates full of | async. Signals collapse
it.
Before:
@Injectable({ providedIn: 'root' })
export class OrdersService {
private orders$ = new BehaviorSubject<Order[]>([]);
private filter$ = new BehaviorSubject<'all' | 'open'>('all');
private loading$ = new BehaviorSubject(false);
readonly visible$ = combineLatest([this.orders$, this.filter$]).pipe(
map(([orders, filter]) => filter === 'all' ? orders : orders.filter(o => o.open)),
);
// ...
}
After:
import { Injectable, computed, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class OrdersService {
private readonly orders = signal<Order[]>([]);
readonly filter = signal<'all' | 'open'>('all');
readonly loading = signal(false);
readonly visible = computed(() => {
const all = this.orders();
return this.filter() === 'all' ? all : all.filter(o => o.open);
});
async load() {
this.loading.set(true);
try {
this.orders.set(await this.api.listOrders());
} finally {
this.loading.set(false);
}
}
markShipped(id: string) {
this.orders.update(list =>
list.map(o => (o.id === id ? { ...o, open: false } : o)));
}
}
Two rules that keep this clean in practice:
- Signals are values, not streams. Keep RxJS where you genuinely have
event streams over time — websockets, debounced search input, retry
pipelines — and convert at the boundary with
toSignal()/toObservable()from@angular/core/rxjs-interop. update(), not mutate.this.orders().push(x)will not notify anything. Always produce a new array or object.
Search input is the classic boundary case:
import { toSignal } from '@angular/core/rxjs-interop';
readonly query = signal('');
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap(q => (q.length < 2 ? of([]) : this.api.search(q))),
),
{ initialValue: [] as Order[] },
);
2. The page component
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import {
IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel,
IonSegment, IonSegmentButton, IonRefresher, IonRefresherContent, IonSpinner,
} from '@ionic/angular/standalone';
import { OrdersService } from './orders.service';
@Component({
selector: 'app-orders',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
IonHeader, IonToolbar, IonTitle, IonContent, IonList, IonItem, IonLabel,
IonSegment, IonSegmentButton, IonRefresher, IonRefresherContent, IonSpinner,
],
template: `
<ion-header><ion-toolbar><ion-title>Orders</ion-title></ion-toolbar></ion-header>
<ion-content>
<ion-refresher slot="fixed" (ionRefresh)="refresh($event)">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<ion-segment [value]="orders.filter()"
(ionChange)="orders.filter.set($any($event.detail.value))">
<ion-segment-button value="all">All</ion-segment-button>
<ion-segment-button value="open">Open ({{ openCount() }})</ion-segment-button>
</ion-segment>
@if (orders.loading()) {
<ion-spinner class="ion-margin"></ion-spinner>
} @else {
<ion-list>
@for (order of orders.visible(); track order.id) {
<ion-item button (click)="open(order)">
<ion-label>
<h2>{{ order.reference }}</h2>
<p>{{ order.customer }}</p>
</ion-label>
</ion-item>
} @empty {
<ion-item lines="none"><ion-label>Nothing here yet.</ion-label></ion-item>
}
</ion-list>
}
</ion-content>
`,
})
export class OrdersPage {
readonly orders = inject(OrdersService);
readonly openCount = computed(() => this.orders.visible().filter(o => o.open).length);
async refresh(ev: CustomEvent) {
await this.orders.load();
(ev.target as HTMLIonRefresherElement).complete();
}
}
Notes that matter on device:
track order.idin@foris not optional. Without a stable key Angular destroys and rebuildsion-items on every change, which is exactly the jank you were trying to remove.- Import individual Ionic standalone components, not
IonicModule. That is what lets the bundler drop the components this page does not use — see performance tuning for the measurement side. - Signals read in the template are tracked automatically, so
OnPushis safe without a singlemarkForCheck().
3. Going zoneless
Zoneless is the payoff: no zone.js patching of every async API, and change
detection that runs only when a signal actually changes.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideIonicAngular } from '@ionic/angular/standalone';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/routes';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideIonicAngular({ mode: 'ios' }),
provideRouter(routes),
],
});
Then remove zone.js from the build so it stops shipping:
// angular.json -> architect.build.options
"polyfills": [] // was ["zone.js"]
// tsconfig.app.json — drop "zone.js" from "types" if present
Rebuild and check the bundle actually shrank (npx source-map-explorer www/main*.js); on the apps we have migrated it is roughly 30–35 KB gzipped
off the initial chunk, and cold start improves because there is no polyfill
patching on boot.
4. What breaks, and the fix
Zoneless does not break Ionic components — they are web components driving
their own rendering — but it does break code that silently relied on
zone.js to trigger a render.
| Symptom | Cause | Fix |
|---|---|---|
UI does not update after a setTimeout, addEventListener, or a promise from a Capacitor plugin | The value lives in a plain class field, not a signal | Make it a signal() and set() it |
| Value updates but only when you tap the screen | Same as above, incidental CD from another event | Same fix |
ChangeDetectorRef.detectChanges() sprinkled in old code | Written for zone-era workarounds | Delete it; convert the state to signals |
| Third-party library never renders | Mutates its own state outside Angular | Wrap its callbacks so they set() a signal, or keep that one component in a zoneful island temporarily |
Tests hang on fixture.whenStable() | fakeAsync/tick assumptions | Use await fixture.whenStable() with the zoneless test provider |
Capacitor listeners are the most common offender in Ionic apps. Convert them at the source:
import { Injectable, signal } from '@angular/core';
import { Network } from '@capacitor/network';
import { App } from '@capacitor/app';
@Injectable({ providedIn: 'root' })
export class DeviceStateService {
readonly online = signal(true);
readonly active = signal(true);
constructor() {
Network.getStatus().then(s => this.online.set(s.connected));
Network.addListener('networkStatusChange', s => this.online.set(s.connected));
App.addListener('appStateChange', s => this.active.set(s.isActive));
}
}
Because signal.set() schedules change detection itself, there is no
NgZone.run() and no runOutsideAngular() anywhere in that service. That is
the whole point.
For tests:
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
// ...
component.orders.filter.set('open');
await fixture.whenStable();
expect(screen.getAllByRole('listitem')).toHaveLength(3);
5. A safe rollout order
- Standalone + control-flow + signal-input schematics. Ship it.
- Convert services to signals one domain at a time, starting with the heaviest list screen. Ship each one.
- Switch every converted component to
ChangeDetectionStrategy.OnPush. Anything that breaks here is state that is not yet a signal — fix it now, whilezone.jsis still there to soften the failure. - Flip
provideZonelessChangeDetection()behind a build flag, run your device test pass (@forlists, refreshers, modals, deep links, push handlers, background resume), then removezone.jsfrom the build. - Keep the flag for one release so you can revert without a hotfix.
Steps 1–3 are worth doing even if you never go zoneless: most of the frame
drops on long Ionic lists come from whole-app change detection, and OnPush
plus signals removes them.
Need this done on a live app?
We do this migration as a scoped engagement — audit, signal conversion of the hot screens, zoneless flip, device regression pass — on apps already in the stores, without pausing feature work. If you have an Ionic Angular app that feels slow on Android and nobody wants to touch the change detection, talk to us or read more about our Ionic consulting engagements.