← Back to blog

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.

Zone.js (old default)
Async event fires → zone intercepts → full tree checked → DOM updated

Cost: O(n) where n = total bindings in tree
Signals (v20 default)
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.

Server renders full HTML
Browser shows static page
User scrolls / clicks
Component hydrates

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?”

Migration note: Existing projects with NgModules still work. Nothing breaks. But the CLI won't generate modules anymore, and the documentation now treats standalone as the only pattern. Run 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: