Angular v20: The One That Kills Zone.js
Zone.js has been on life support for two years. Angular 20 finally pulls the plug. Signals are no longer an opt-in experiment — they’re the default reactive primitive, wired directly into change detection. If you’ve been putting off the migration, your grace period just ended.
Signals Run Change Detection Now
Before v20, Angular used zone.js to intercept every async operation — setTimeout, Promise.then, DOM events — and trigger change detection across your entire component tree. It worked, but it was a sledgehammer. Every click re-checked everything.
With v20, signals tell Angular exactly what changed. Nothing else gets checked.
@Component({
template: `
<h1>{{ title() }}</h1>
<p>Items: {{ itemCount() }}</p>
<button (click)="addItem()">Add</button>
`
})
export class DashboardComponent {
items = signal<string[]>([]);
title = signal('Dashboard');
itemCount = computed(() => this.items().length);
addItem() {
this.items.update(list => [...list, `Item ${list.length + 1}`]);
}
}
When addItem() runs, Angular knows only items and itemCount changed. It updates those two bindings. title doesn’t get touched. In a zone.js world, the entire template would re-evaluate. In a signal world, it’s surgical.
Async event fires → zone intercepts → full tree checked → DOM updated
Cost: O(n) where n = total bindings in tree
Signal updates → dependents notified → only affected bindings updated
Cost: O(k) where k = changed bindings
The effect() API also ships as stable in v20. Use it for side effects that should run whenever signals change — logging, localStorage syncing, analytics events:
export class SettingsComponent {
theme = signal<'light' | 'dark'>('light');
constructor() {
effect(() => {
document.body.setAttribute('data-theme', this.theme());
localStorage.setItem('theme', this.theme());
});
}
}
No subscriptions. No teardown. The effect tracks its dependencies automatically and cleans up when the component is destroyed.
Incremental Hydration for SSR
SSR in Angular used to be all-or-nothing. The server renders HTML, the browser downloads the full app bundle, hydrates everything at once. Your Time to Interactive suffered because the browser was busy hydrating a footer the user hasn’t scrolled to yet.
Angular 20 makes hydration incremental by default. Components hydrate on triggers — when they enter the viewport, on interaction, or on idle.
@Component({
template: `
@defer (on viewport) {
<app-comments [postId]="postId" />
} @placeholder {
<div class="skeleton-comments"></div>
}
`
})
export class BlogPostComponent {
postId = input.required<string>();
}
The comments component doesn’t load or hydrate until the user scrolls to it. The server still renders it as HTML for SEO. But the JavaScript? It waits.
For content-heavy apps — blogs, e-commerce, dashboards with lots of below-the-fold content — this is a measurable improvement in TTI. Real-world reports show 30-40% faster interactive times on pages with deferred components.
Standalone by Default, NgModule Deprecated
NgModule is officially deprecated. New projects generated with the CLI don’t include one. No AppModule. No declarations array. No imports array pointing to other modules. Components, directives, and pipes are all standalone by default.
Here’s what a fresh v20 project looks like:
@Component({
selector: 'app-root',
imports: [RouterOutlet, NavComponent],
template: `
<app-nav />
<router-outlet />
`
})
export class AppComponent {}
The component declares its own dependencies via imports. No intermediary module. No mental mapping of “which module provides this component?”
ng generate @angular/core:standalone to migrate incrementally.
If you’ve been putting off the standalone migration because “it still works fine with modules” — that’s true, but new hires will learn Angular without modules. Blog posts and Stack Overflow answers will assume standalone. The ecosystem is moving, and staying on modules means swimming upstream.
CLI Gets Smarter
Two CLI additions worth knowing about.
ng diagnose scans your project for common performance issues. Oversized bundles, missing lazy loading, unused imports, zone.js when you could go zoneless. Think of it as a linter for architectural decisions:
$ ng diagnose
[WARN] Component "AdminDashboard" has 47 direct imports.
Consider splitting into smaller components.
[WARN] Route "/settings" is eagerly loaded but rarely visited.
Consider lazy loading with loadComponent().
[INFO] Your project still uses zone.js.
Run "ng update @angular/core --migrate-zoneless" to switch.
Better error messages now include direct links to the relevant docs page. Instead of a cryptic NullInjectorError: No provider for SomeService, you get a clear explanation and a URL to the dependency injection guide. Small change, massive quality-of-life improvement for anyone who’s ever pasted an Angular error into Google.
What You Should Actually Do
Not every v20 feature needs your attention today. Here’s the priority order:
| Action | Effort | Impact | When |
|---|---|---|---|
Run ng update |
Low | High | Now |
Run ng diagnose on your project |
Low | Medium | Now |
| Start writing new components with signals | Low | High | Now |
| Migrate existing components to standalone | Medium | Medium | Next sprint |
| Add incremental hydration to SSR app | Medium | High (if SSR) | Next sprint |
| Drop zone.js entirely | High | High | When ready |
The low-effort items are no-brainers. Update, diagnose, start using signals in new code. The bigger migrations — standalone and zoneless — can happen incrementally. Angular’s schematics handle most of the mechanical work. The hard part is testing afterward.
The one thing I’d avoid: rewriting working code purely to adopt signals. If a component works and isn’t a performance bottleneck, leave it. Write new code the v20 way. Migrate old code when you’re already touching it. That’s how you adopt a major release without derailing your roadmap.
Sources:
- Angular Blog — official Angular release announcements