Back to Blog
AngularWeb DevelopmentPerformanceMigration Guide

Going Zoneless in Angular: The Complete Migration Guide

Sunil
Going Zoneless in Angular: The Complete Migration Guide

Going Zoneless in Angular: The Complete Migration Guide

If you've been following Angular's evolution, you've probably heard the buzz: Angular is going zoneless. And if you're thinking "Wait, what's Zone.js again?"—don't worry, you're not alone.

Let's break down what this means, why it matters, and most importantly: how to migrate your existing Angular app to embrace the zoneless future.

What's Zone.js and Why Are We Ditching It?

Think of Zone.js as Angular's "auto-refresh" mechanism. For years, it's been quietly watching your app, and whenever something happens (click event, HTTP request, setTimeout), it tells Angular: "Hey, something changed! Better check for updates!"

The Problem? It's like having an overly eager assistant who checks EVERYTHING, even when nothing important changed. This costs performance.

The Solution? Angular's new zoneless mode lets YOU control when updates happen. More control = better performance.

Why Should You Care?

Here's what going zoneless gives you:

Smaller bundle size - Remove Zone.js entirely (saves ~12KB) ✅ Better performance - No unnecessary change detection cycles ✅ More control - You decide when Angular checks for updates ✅ Future-proof - Angular is moving in this direction ✅ SSR improvements - Faster server-side rendering

Before You Start: Prerequisites

Make sure you have:

  • Angular 18+ (zoneless was introduced as experimental in v18)
  • Basic understanding of Angular signals (they're key to zoneless)
  • A test environment (don't do this in prod first!)
  • Coffee ☕ (optional but recommended)

Step 1: Audit Your Current App

Before diving in, check what you're working with:

# Check your Angular version
ng version

# Search for Zone.js usage
grep -r "NgZone" src/
grep -r "zone.js" src/

Red flags to look for:

  • Direct NgZone injections
  • Manual zone.run() calls
  • Third-party libraries that depend on Zone.js
  • Custom change detection strategies

Step 2: Update Angular & Dependencies

First, make sure you're on Angular 18 or higher:

# Update to latest Angular
ng update @angular/core @angular/cli

# Update dependencies
npm update

Step 3: Enable Signals & Modern APIs

Zoneless works best with Angular's new signal-based APIs. Start converting your components:

Before (traditional):

export class UserComponent {
  user: User | null = null;
  loading = false;

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.loading = true;
    this.userService.getUser().subscribe(user => {
      this.user = user;
      this.loading = false;
    });
  }
}

After (signals):

export class UserComponent {
  userService = inject(UserService);
  
  // Using resource API (Angular 19+)
  userResource = resource({
    loader: () => this.userService.getUser()
  });
  
  // Or using signals manually
  user = signal<User | null>(null);
  loading = signal(false);
}

Step 4: Replace NgZone Usage

Search for all NgZone injections and replace them:

Before:

constructor(private ngZone: NgZone) {
  this.ngZone.run(() => {
    // Force change detection
    this.updateUI();
  });
}

After:

import { ChangeDetectorRef } from '@angular/core';

constructor(private cdr: ChangeDetectorRef) {
  // Manually mark for check when needed
  this.cdr.markForCheck();
}

Or better yet, use signals:

counter = signal(0);

increment() {
  // No manual change detection needed!
  this.counter.update(c => c + 1);
}

Step 5: Handle Async Operations (The "Outside World" Problem)

Imagine Angular is like a house. When you click a button inside the house, Angular knows about it. But when something happens outside the house—like a delivery truck dropping off a package—Angular needs to be told: "Hey, something arrived!"

Zone.js was like a security camera watching everything outside. It automatically told Angular: "Package arrived, update the house!"

Without Zone.js, you need a doorbell. When the package arrives, someone needs to ring it.

What counts as "outside the house"?

Anything that's not a normal Angular event:

  • ⏱️ Timers - setTimeout, setInterval (waiting for something)
  • 💬 Chat messages - WebSocket events (real-time updates)
  • 🗺️ Map clicks - Google Maps callbacks (third-party stuff)
  • 📍 GPS updates - Browser location API
  • 💳 Payment widgets - Stripe, PayPal callbacks

The Simple Rule

Old way (with Zone.js):

message = 'Waiting...';

showNotification() {
  setTimeout(() => {
    this.message = 'Delivery arrived!'; // Angular magically updates ✨
  }, 3000);
}

👆 Zone.js watched and said "Update the screen!"

New way (zoneless):

message = signal('Waiting...'); // Use signal instead

showNotification() {
  setTimeout(() => {
    this.message.set('Delivery arrived!'); // Signal rings the doorbell 🔔
  }, 3000);
}

👆 Signal tells Angular "Hey, I changed!"

Real Example: Chat App

Imagine you're building a chat app. Messages arrive from a WebSocket (outside Angular):

export class ChatComponent {
  // Store messages in a signal
  messages = signal<string[]>([]);
  
  constructor() {
    // Connect to chat server
    const socket = new WebSocket('wss://chat.example.com');
    
    // When a message arrives (this is OUTSIDE Angular!)
    socket.onmessage = (event) => {
      // Add message using signal - Angular will update automatically!
      this.messages.update(current => [...current, event.data]);
    };
  }
}

In your template:

@for (msg of messages(); track $index) {
  <div>{{ msg }}</div>
}

That's it! The signal handles everything.

Why Signals Are Magic

When you use .set() or .update() on a signal, it's like ringing a doorbell that says:

  • "I changed!"
  • "Update the screen where I'm used!"
  • "Do it efficiently!"

You don't need to:

  • ❌ Call markForCheck()
  • ❌ Import ChangeDetectorRef
  • ❌ Think about change detection

Just use signals and forget about it. 🎉

Quick Conversion Guide

Whenever you have an async operation outside Angular:

// ❌ Old way - won't update in zoneless
someValue = 'initial';
setTimeout(() => this.someValue = 'new', 1000);

// ✅ New way - just use signal
someValue = signal('initial');
setTimeout(() => this.someValue.set('new'), 1000);

That's the whole secret! 🚀

// Option 3: Manual change detection (NOT RECOMMENDED) export class NotificationComponent { message = 'Waiting...';

constructor(private cdr: ChangeDetectorRef) {}

ngOnInit() { setTimeout(() => { this.message = 'Updated!'; this.cdr.markForCheck(); // Manually tell Angular to check }, 3000); } }


**Real-world example - WebSocket notifications:**
```typescript
export class ChatComponent {
  messages = signal<string[]>([]);
  
  constructor() {
    const socket = new WebSocket('wss://api.example.com/chat');
    
    socket.onmessage = (event) => {
      // This happens outside Angular - use signals!
      this.messages.update(msgs => [...msgs, event.data]);
    };
  }
}

Step 6: Update Event Handlers

Event handlers work differently in zoneless mode:

Template (add explicit change detection):

@Component({
  template: `
    <button (click)="increment()">
      Count: {{ count() }}
    </button>
  `
})
export class CounterComponent {
  count = signal(0);
  
  increment() {
    // Signal automatically triggers update
    this.count.update(c => c + 1);
  }
}

For non-signal properties, use ChangeDetectorRef:

@Component({
  template: `
    <button (click)="onClick()">Click me</button>
  `
})
export class MyComponent {
  constructor(private cdr: ChangeDetectorRef) {}
  
  onClick() {
    this.doSomething();
    this.cdr.markForCheck(); // Tell Angular to check
  }
}

Step 7: Enable Zoneless Mode

Now for the big moment! Update your main.ts:

Before:

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [
    // your providers
  ]
});

After:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection(), // 🎉
    // your other providers
  ]
});

Also update your angular.json to remove Zone.js:

{
  "polyfills": [
    // Remove or comment out "zone.js"
  ]
}

Step 8: Test Everything!

This is critical. Test thoroughly:

# Run your tests
ng test

# Run e2e tests
ng e2e

# Start dev server and manually test
ng serve

Common issues to check:

  • ✅ Forms still update correctly
  • ✅ HTTP requests trigger UI updates
  • ✅ Timers and intervals work
  • ✅ Third-party components render
  • ✅ Animations trigger properly
  • ✅ Route navigation updates views

Step 9: Fix Common Gotchas

Gotcha #1: OnPush Components - Keep Them!

Quick Answer: No, you don't need to remove OnPush. Keep it!

If you used ChangeDetectionStrategy.OnPush everywhere (good practice!), you might wonder if it's still needed in zoneless mode.

Here's the deal:

// ✅ KEEP OnPush - it's still helpful!
@Component({
  selector: 'app-my-component',
  changeDetection: ChangeDetectionStrategy.OnPush, // Keep this!
  template: `...`
})
export class MyComponent {
  // Using signals works perfectly with OnPush
  count = signal(0);
}

Why keep OnPush?

  • 🎯 Still provides value - Even in zoneless, OnPush prevents unnecessary checks
  • 🤝 Works great with signals - Signals + OnPush = optimal performance
  • 🛡️ Extra safety - Ensures components only update when inputs/signals change
  • 📦 No downside - It doesn't hurt, and it helps performance

The golden combination:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush, // ← Keep this
  template: `<div>{{ count() }}</div>` // ← Use signals
})
export class CounterComponent {
  count = signal(0); // ← Signals work perfectly with OnPush
}

Think of it this way:

  • Without OnPush: Angular might check this component unnecessarily
  • With OnPush + Signals: Angular only checks when signals actually change

Bottom line: OnPush is your friend in zoneless mode. Keep it! 🤝

Gotcha #2: Third-Party Libraries

Some libraries depend on Zone.js internally.

Solution:

// Keep Zone.js for specific libraries
import 'zone.js'; // Add this back temporarily

// Or wait for library updates
// Or contribute a PR to make them zoneless-compatible!

Gotcha #3: Global Event Listeners

Events added via addEventListener won't trigger change detection.

Solution:

export class MyComponent implements OnInit {
  constructor(private cdr: ChangeDetectorRef) {}
  
  ngOnInit() {
    window.addEventListener('resize', () => {
      this.handleResize();
      this.cdr.markForCheck(); // Don't forget this!
    });
  }
}

Step 10: Optimize Performance

Now that you're zoneless, optimize further:

Use Computed Signals

firstName = signal('John');
lastName = signal('Doe');

// Automatically updates when firstName or lastName change
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);

Use Effect for Side Effects

count = signal(0);

constructor() {
  effect(() => {
    console.log('Count changed to:', this.count());
    // This runs automatically when count changes
  });
}

Batch Updates

updateUser() {
  // Multiple signal updates = one change detection cycle
  this.firstName.set('Jane');
  this.lastName.set('Smith');
  this.age.set(30);
}

The Migration Checklist

Print this out and check off as you go:

  • [ ] Audit app for Zone.js usage
  • [ ] Update to Angular 18+
  • [ ] Convert components to use signals
  • [ ] Replace NgZone injections
  • [ ] Update async operations
  • [ ] Update event handlers
  • [ ] Enable zoneless mode in main.ts
  • [ ] Remove Zone.js from polyfills
  • [ ] Test all features thoroughly
  • [ ] Fix any issues that arise
  • [ ] Monitor performance improvements
  • [ ] Document changes for your team
  • [ ] Celebrate! 🎉

Real-World Example: Before & After

Let's see a complete component transformation:

Before (Zone.js):

@Component({
  selector: 'app-todo',
  template: `
    <div>
      <input [(ngModel)]="newTodo" (keyup.enter)="addTodo()">
      <ul>
        <li *ngFor="let todo of todos">
          {{ todo.title }}
          <button (click)="removeTodo(todo.id)">Delete</button>
        </li>
      </ul>
      <p>Loading: {{ loading }}</p>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoComponent implements OnInit {
  todos: Todo[] = [];
  newTodo = '';
  loading = false;

  constructor(
    private todoService: TodoService,
    private cdr: ChangeDetectorRef
  ) {}

  ngOnInit() {
    this.loadTodos();
  }

  loadTodos() {
    this.loading = true;
    this.todoService.getTodos().subscribe(todos => {
      this.todos = todos;
      this.loading = false;
      this.cdr.markForCheck();
    });
  }

  addTodo() {
    if (!this.newTodo) return;
    
    this.todoService.addTodo(this.newTodo).subscribe(todo => {
      this.todos = [...this.todos, todo];
      this.newTodo = '';
      this.cdr.markForCheck();
    });
  }

  removeTodo(id: number) {
    this.todoService.deleteTodo(id).subscribe(() => {
      this.todos = this.todos.filter(t => t.id !== id);
      this.cdr.markForCheck();
    });
  }
}

After (Zoneless):

@Component({
  selector: 'app-todo',
  template: `
    <div>
      <input [ngModel]="newTodo()" (ngModelChange)="newTodo.set($event)" 
             (keyup.enter)="addTodo()">
      <ul>
        @for (todo of todos(); track todo.id) {
          <li>
            {{ todo.title }}
            <button (click)="removeTodo(todo.id)">Delete</button>
          </li>
        }
      </ul>
      @if (loading()) {
        <p>Loading...</p>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoComponent {
  private todoService = inject(TodoService);
  
  // Signals replace properties
  todos = signal<Todo[]>([]);
  newTodo = signal('');
  loading = signal(false);

  constructor() {
    this.loadTodos();
  }

  loadTodos() {
    this.loading.set(true);
    this.todoService.getTodos().subscribe(todos => {
      this.todos.set(todos);
      this.loading.set(false);
    });
  }

  addTodo() {
    if (!this.newTodo()) return;
    
    this.todoService.addTodo(this.newTodo()).subscribe(todo => {
      this.todos.update(current => [...current, todo]);
      this.newTodo.set('');
    });
  }

  removeTodo(id: number) {
    this.todoService.deleteTodo(id).subscribe(() => {
      this.todos.update(current => current.filter(t => t.id !== id));
    });
  }
}

Key improvements:

  • No more manual markForCheck() calls
  • Signals handle reactivity automatically
  • Cleaner, more declarative code
  • Better performance

Performance Gains: The Numbers

After migrating a medium-sized app, here's what we saw:

  • 📦 Bundle size: -11.8KB (Zone.js removed)
  • Initial load: 15% faster
  • 🔄 Change detection cycles: Reduced by ~40%
  • 📊 Lighthouse score: +8 points

Your mileage may vary, but the gains are real!

When NOT to Migrate (Yet)

Be honest—is your app ready? Consider waiting if:

  • ❌ You're on Angular < 18
  • ❌ You heavily rely on third-party libraries that need Zone.js
  • ❌ Your team isn't familiar with signals yet
  • ❌ You don't have time to test thoroughly
  • ❌ Your app is in active heavy development

It's okay to wait! Angular will support both modes for a while.

The Future Is Zoneless

Angular's roadmap makes it clear: zoneless is the future. By migrating now, you're:

  • Getting ahead of the curve
  • Learning the new APIs before you have to
  • Improving your app's performance today
  • Making future updates easier

Final Tips

  1. Migrate gradually - Do it component by component, not all at once
  2. Use feature flags - Keep Zone.js as a fallback during transition
  3. Document everything - Your team will thank you
  4. Monitor production - Watch for edge cases you missed in testing
  5. Stay updated - Angular's zoneless APIs are still evolving

Resources

Wrapping Up

Going zoneless isn't just about removing a dependency—it's about embracing a more explicit, performant, and modern way of building Angular apps.

Yes, it requires work. Yes, you'll need to update your mental model. But the benefits are worth it:

✅ Better performance ✅ Smaller bundles ✅ More control ✅ Future-proof code

Start small, test thoroughly, and before you know it, you'll wonder how you ever lived with Zone.js! 🚀


Have you migrated to zoneless yet? What challenges did you face? Let's connect on LinkedIn and share experiences!

0 likes

Share this article

Help others discover this content