Create observers inside NgZone.runOutsideAngular() so their callbacks do not trigger change detection, and re-enter with zone.run() — or better, set a signal — only when a callback actually changes something a template displays.

Problem / Scenario Context

An Angular admin app has a long table with a scroll-spy header, lazy-loaded avatars and a sticky toolbar, each driven by an IntersectionObserver, plus several ResizeObservers for responsive widgets. Scrolling the table feels sluggish. The Angular DevTools profiler shows change detection running dozens of times per second during scroll, across the entire component tree, even though most observer callbacks change nothing visible — the lazy-image callback only sets an src attribute directly.

Zone.js is doing exactly what it was designed to do. The Angular Observer Directives topic introduces directives; this page is about zones.

Mechanics Explanation

In zone-based Angular (the default for most existing apps), Zone.js monkey-patches asynchronous browser APIs — timers, events, promises, and observer callbacks including IntersectionObserver, ResizeObserver and MutationObserver. Any task that runs inside the Angular zone notifies Angular when it completes, and Angular runs change detection from the root.

So every observer callback — every scroll batch, every resize frame — becomes a full change detection pass, whether or not the callback changed any bound state. With OnPush components the pass is cheaper, but it still walks the tree.

Observers created inside runOutsideAngular() capture the outer zone, and their callbacks run there. Angular is not notified. If a callback needs to update template state, it has three options:

  • Set a signal — templates reading it are refreshed; no global pass needed (Angular 17+).
  • zone.run(() => ...) — re-enter the zone for that update; triggers change detection.
  • ChangeDetectorRef.markForCheck() / detectChanges() — targeted, for OnPush components.

Observer Inside Versus Outside the Angular ZoneTwo columns. Created inside the zone, every observer callback notifies Angular and runs change detection across the tree, even when nothing bound changed. Created outside the zone, callbacks run silently; the few that change template state set a signal or re-enter the zone for that one update.Inside NgZone (default)Every callback → change detection from the rootEven callbacks that change nothing boundDozens of passes per second while scrollingrunOutsideAngular()Callbacks run without notifying AngularDOM-only work (src, classes) needs nothing elseState changes: set a signal, or zone.run()

Comparison Table: Re-Entry Options

Callback effect Re-entry needed Best option
Sets img.src, toggles a class via Renderer2 / DOM none stay outside
Updates a template-bound value yes set a signal
Emits an @Output a parent binds to yes, for the parent zone.run(() => out.emit(v)) or output() + signal in parent
Updates a service's state other components read depends signal in the service
Navigates or opens a dialog yes zone.run
Analytics beacon none stay outside

Minimal Reproducible Example

TypeScript
@Directive({ selector: 'img[lazySrc]', standalone: true })
export class LazySrcDirective implements OnInit, OnDestroy {
  @Input({ required: true }) lazySrc!: string;
  private io?: IntersectionObserver;
  constructor(private el: ElementRef<HTMLImageElement>) {}
  ngOnInit() {
    this.io = new IntersectionObserver(([e]) => {      // inside the zone
      if (e.isIntersecting) { this.el.nativeElement.src = this.lazySrc; this.io?.disconnect(); }
    });
    this.io.observe(this.el.nativeElement);
  }
  ngOnDestroy() { this.io?.disconnect(); }
}

Every avatar in a 500-row table triggers a full change detection pass when it crosses into view — hundreds of passes on the first scroll.

Production-Safe Solution

TypeScript
import { Directive, ElementRef, NgZone, DestroyRef, inject, input, afterNextRender } from '@angular/core';

@Directive({ selector: 'img[lazySrc]', standalone: true })
export class LazySrcDirective {
  readonly lazySrc = input.required<string>();
  private el = inject(ElementRef<HTMLImageElement>);
  private zone = inject(NgZone);

  constructor() {
    const destroyRef = inject(DestroyRef);
    afterNextRender(() => {
      const io = this.zone.runOutsideAngular(() => new IntersectionObserver(([e]) => {
        if (!e.isIntersecting) return;
        this.el.nativeElement.src = this.lazySrc();      // DOM only: no change detection needed
        io.disconnect();
      }, { rootMargin: '300px' }));
      io.observe(this.el.nativeElement);
      destroyRef.onDestroy(() => io.disconnect());
    });
  }
}
TypeScript
// Scroll-spy header: the callback changes template state → signal.
@Component({
  selector: 'app-table-header',
  standalone: true,
  template: `<h2>{{ activeSection() }}</h2>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableHeaderComponent {
  readonly activeSection = signal('Overview');
  private zone = inject(NgZone);

  constructor() {
    const destroyRef = inject(DestroyRef);               // capture in the injection context
    afterNextRender(() => {
      const io = this.zone.runOutsideAngular(() => new IntersectionObserver((entries) => {
        const top = entries.filter((e) => e.isIntersecting)
          .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
        if (top) this.activeSection.set(top.target.getAttribute('data-section')!);   // refreshes this view only
      }, { rootMargin: '0px 0px -80% 0px' }));
      document.querySelectorAll('[data-section]').forEach((s) => io.observe(s));
      destroyRef.onDestroy(() => io.disconnect());
    });
  }
}

The lazy image needs no Angular involvement at all. The scroll-spy sets a signal only when the active section changes, so only its OnPush view refreshes. Neither triggers application-wide change detection. Both capture DestroyRef in the constructor, because inject() only works in an injection context, not inside a later callback.

Change Detection Passes During One Table ScrollA bar chart of change detection passes during one scroll through a five-hundred-row table. Observers inside the zone caused about four hundred and eighty passes. Observers outside the zone with zone.run for the scroll-spy caused about twelve. Observers outside the zone with a signal for the scroll-spy caused none at the application level, only a dozen local view refreshes.One scroll through a 500-row tableobservers inside the zone~480 app-wide passesoutside + zone.run for spy~12 passesoutside + signal for spy0 app-wide passes

Verifying With the Profiler

Angular DevTools' Profiler records each change detection cycle and its source. Before the change, scrolling shows a dense sequence of cycles; after, cycles appear only when the scroll-spy's active section changes (and, with signals, as local view refreshes rather than root passes).

A second check in the browser's Performance panel: look at the tasks after each paint while scrolling. Observer callbacks outside the zone appear as short tasks with only your code; inside the zone, each is followed by ApplicationRef.tick and a long tail of template checks.

In zoneless applications, none of this is necessary — there is no zone to trigger passes — but the second half of the advice still applies: update signals only when values change, so zoneless scheduling stays idle.

Converting an Observer to Run Outside the ZoneFour steps. Wrap the observer construction in runOutsideAngular. Classify each callback effect as DOM-only, template state, or navigation. Leave DOM-only effects as they are, move template state into signals, and wrap navigation or dialog calls in zone.run. Verify with the Angular DevTools profiler that scrolling no longer produces application-wide cycles.1Wrap constructionzone.runOutsideAngular(() => new IntersectionObserver(…))2Classify effectsDOM-only, template state, or navigation?3Route eachDOM: as is; state: signal; navigation: zone.run4VerifyProfiler: no app-wide cycles while scrolling

Verification Steps

  • Record with Angular DevTools Profiler while scrolling; app-wide cycles should drop to near zero.
  • Confirm templates still update where observers change bound state (via signals or zone.run).
  • Check OnPush components that rely on observer state refresh correctly.
  • Test in zoneless mode if the app is migrating; behaviour should be identical.
  • Look for ApplicationRef.tick after observer tasks in a Performance trace; it should be gone.

Common Mistakes to Avoid

  • Creating observers in ngOnInit inside the zone. Every callback becomes a full pass.
  • Updating bound fields from outside the zone without signals. The template does not refresh.
  • Wrapping every callback in zone.run. It reintroduces the problem; re-enter only for state changes.
  • Calling inject() inside callbacks. Capture dependencies in the constructor.

FAQ

Does Zone.js really patch ResizeObserver and IntersectionObserver?

Yes, recent Zone.js versions patch observer callbacks so that they run in the zone in which the observer was created. Creating the observer outside Angular's zone makes its callbacks run outside too.

Will my template update if I change a normal field outside the zone?

Not automatically. Angular is not notified, so the view is not checked. Use a signal, call markForCheck on the component's ChangeDetectorRef, or wrap the update in zone.run.

Is runOutsideAngular needed in zoneless apps?

No. Without Zone.js there is nothing to escape. It is harmless to keep, which helps libraries that must support both modes.

What about MutationObserver?

The same applies: its callbacks run in the zone they were created in. MutationObservers watching busy DOM regions are especially costly inside the zone, since every framework render can trigger them.

Does OnPush make this unnecessary?

It reduces the cost of each pass but does not stop the passes. Running observers outside the zone removes the passes entirely.

How do I find which observers are running inside the zone?

Record a Performance trace while scrolling and look for ApplicationRef.tick immediately after observer callback tasks. Each such pairing is an observer created inside the zone. Angular DevTools' profiler also shows the source of each change detection cycle.

Should a shared observer service run outside the zone?

Yes. Create the observer inside runOutsideAngular in the service, and let each subscriber decide whether its effect needs a signal update or zone re-entry. That keeps the common case — DOM-only work — free of change detection.

How do I emit an output from outside the zone?

Emitting itself works anywhere; the question is whether the parent's handler updates template state. Wrap the emit in zone.run, or have the parent store the value in a signal so its template refreshes regardless.


↑ Back to Angular Observer Directives