Angular signals and zoneless mode are the biggest Angular change since Ivy. They replace Zone.js with fine-grained, explicit reactivity that makes change detection faster and more predictable. Here's what it actually changes, on the performance side, in a real application, with the migration code.
Version note (June 2026). Signals (
signal/computed/effect), signal inputs (input()) andmodel()are stable since Angular 19.linkedSignal()is stable since Angular 20, as is zoneless change detection (provideZonelessChangeDetection, stable in 20.2). The resource API (resource(),httpResource()) is still in developer preview: useful, but don't treat it as frozen. Zoneless becomes the default from Angular 21.
The problem Zone.js created
From the start, Angular relied on Zone.js: a library that monkey-patches every async browser API (setTimeout, setInterval, addEventListener, Promise, fetch…) to know when to re-run change detection. Convenient: you never had to tell Angular to "refresh itself". But expensive on two fronts.
First the memory and bundle cost: Zone.js weighs roughly 13 kB gzipped (~100 kB uncompressed) added to the polyfills. Then, and above all, the runtime cost: by default, any async task triggers a detection pass that re-checks the entire component tree from the root, even branches that didn't change. A stray setInterval, a mousemove, a third-party lib polling in the background. All of it makes Angular work for nothing.
ChangeDetectionStrategy.OnPush mitigated this by pruning subtrees, but you stayed in a model where Angular checks "just in case". Signals flip the logic: a component updates only if a value it actually reads in its template has changed.
Angular signals in 3 primitives
import { signal, computed, effect } from '@angular/core';
// 1. signal: a reactive state, a writable source of truth
const quantity = signal(1);
const price = signal(29.9);
// 2. computed: a derived value, lazy and memoized
const total = computed(() => quantity() * price());
// 3. effect: a side effect re-run when a read dependency changes
effect(() => console.log(`Total: ${total()} €`));
quantity.set(3); // direct write
quantity.update(q => q + 1); // write derived from the previous value
You read a signal by calling it (quantity()). That read is what creates the dependency: a computed or an effect only "sees" the signals it actually calls at runtime.
| Primitive | Role | Write | Recompute |
|---|---|---|---|
signal() |
Writable source state | set() / update() |
- |
computed() |
Memoized derived value | read-only | Lazy, on read |
effect() |
Reactive side effect | - | When a read dependency changes |
Takeaway: a
computedis lazy and memoized: it only recomputes if one of its read dependencies changed, and only when you read it again. As long as nobody reads the value, no computation happens. That's where the performance is won (exactly where a getter, by contrast, recomputes on every detection cycle).
A word on effect: it's for side effects (logging, syncing with an imperative API, localStorage…), not for deriving state (that's what computed is for). And it runs in an injection context, so you typically call it in the constructor or as a class field.
Signal inputs: reactive @Input()
The input() function replaces the @Input() decorator and becomes a signal you can compose directly inside computed. Stable since Angular 19.
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
@Component({
selector: 'app-price-tag',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span>{{ finalPrice() }} €</span>`,
})
export class PriceTagComponent {
// input.required: no default value, enforced by the type system
price = input.required<number>();
// input: default value, optional transform
discount = input(0, { transform: (v: number) => Math.min(Math.max(v, 0), 100) });
finalPrice = computed(() => this.price() * (1 - this.discount() / 100));
}
When the component needs to write the value back and send it up to the parent (form field, slider, date picker…), use model() instead of input(): it creates both an input and a xxxChange output, which makes the [(banana-in-a-box)] syntax possible.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-rating',
template: `<button (click)="value.set(value() + 1)">{{ value() }}</button>`,
})
export class RatingComponent {
// two-way: the parent writes <app-rating [(value)]="rating" />
value = model(0);
}
| API | Direction | Read/write | Use case |
|---|---|---|---|
input() |
parent → child | read-only | Standard incoming data |
input.required<T>() |
parent → child | read-only | Required (typed) data |
model() |
parent ↔ child | read and write | Form components, [(value)] |
linkedSignal and the resource API
linkedSignal() (stable in Angular 20) solves a concrete case: a writable state that must reset when a source changes. The textbook example is a select whose chosen option should fall back to the first one whenever the option list changes.
import { signal, linkedSignal } from '@angular/core';
const options = signal(['S', 'M', 'L']);
// writable like a signal, but reset whenever options() changes
const choice = linkedSignal(() => options()[0]);
choice.set('L'); // the user picks
options.set(['XS', 'S']); // the source changes → choice resets to 'XS'
The resource API (resource(), and httpResource() for HTTP) ties a parameters signal to an async load and exposes value(), status(), error(). Handy for reactive data fetching, but in developer preview: the API may still change, so isolate it behind a service layer if you adopt it early.
import { resource, signal } from '@angular/core';
const userId = signal(1);
// re-runs automatically when userId() changes (API still experimental)
const user = resource({
params: () => ({ id: userId() }),
loader: ({ params }) => fetch(`/api/users/${params.id}`).then(r => r.json()),
});
Zoneless mode
From Angular 20 (stable in 20.2), you can remove Zone.js entirely. Detection is no longer triggered "just in case" but only by explicit signals: a write to a signal read in a template, template events ((click)), an async pipe emitting, markForCheck(), or the completion of a set/update.
// app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [provideZonelessChangeDetection()],
};
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';
bootstrapApplication(AppComponent, appConfig);
You still need to remove zone.js from polyfills in angular.json (and any import 'zone.js'), otherwise you keep paying for the polyfill for nothing.
| Criterion | With Zone.js | Zoneless |
|---|---|---|
| Detection trigger | Any async task | Signals, events, async pipe, markForCheck |
| Components re-checked | Whole tree (minus OnPush pruning) |
Only the path of the data that changed |
| Polyfill bundle | + Zone.js (~13 kB gzip) | Removed |
| Predictability | Implicit | Explicit |
| Status | Legacy | Stable (20.2), default in v21 |
Takeaway: zoneless doesn't "magically make the app fast". It removes the global trigger and makes you responsible for notifications. If your state already runs on signals and
OnPush, the switch is nearly transparent. Otherwise, it's the updates made outside a signal/event (a direct object mutation, a third-party lib callback) that will stop refreshing the UI, hence the migration order below.
Migrate without a rewrite
Good news: migration is incremental. You introduce signals component by component while keeping Zone.js, then switch to zoneless last, once the app is ready. Order matters: each step is useful on its own and lowers the risk of the next.
- Move components to
OnPush. A healthy prerequisite, already beneficial under Zone.js, and it surfaces the components that relied on global detection. - Convert local state to signals (
signal+set/update). - Replace derived values with
computedinstead of getters called from the template. - Migrate
@Input()toinput()(and@Output()tooutput(), queries to their signal versions) whenever you touch a component. The official schematics automate most of it:ng generate @angular/core:signal-input-migration, then:output-migrationand:signal-queries-migration. - Enable zoneless (
provideZonelessChangeDetection) once the app is mostly signal-based, and removezone.jsfrom the polyfills.
// Before: getter recomputed on EVERY change detection cycle
get total() {
return this.items.reduce((sum, i) => sum + i.price, 0);
}
// After: computed, recomputed only when items() changes, and only on read
items = signal<Item[]>([]);
total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
During the transition, watch for in-place mutations (this.items.push(...)): with a signal you must go through update(items => [...items, newOne]) to notify dependencies. That's the number-one pitfall when migrating.
What it changes in practice
- Targeted detection: no more re-checking the whole tree on every click or timer; Angular only recomputes the path of read signals that changed.
- Lighter bundle: ~13 kB gzip of Zone.js removed from the polyfills.
- More readable code: data dependencies are explicit, and the
computedshows exactly what a value depends on. - Easier debugging: you know why a component updates, instead of chasing an invisible global trigger.
The real gain depends on the app: on high-event-frequency screens (tables, real-time dashboards, rich forms), targeted detection makes the difference; on a static page, the main benefit is readability and the ~13 kB saved.
Going further
In line with these Angular optimizations, see also my field report: How I optimized an Angular application's performance by 40%.
Want to modernize an Angular application, move to signals, or kick off a zoneless switch without breaking production? Let's talk.