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, forOnPushcomponents.
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
@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
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());
});
}
}
// 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.
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.
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
OnPushcomponents that rely on observer state refresh correctly. - Test in zoneless mode if the app is migrating; behaviour should be identical.
- Look for
ApplicationRef.tickafter observer tasks in a Performance trace; it should be gone.
Common Mistakes to Avoid
- Creating observers in
ngOnInitinside 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.
Related
- Angular ResizeObserver Directive with Signals — signals and a shared service
- Angular Infinite Scroll Directive with IntersectionObserver — re-entering the zone for data loads
- Keeping Observer Callbacks Under the INP Budget — why the extra passes hurt interactions
↑ Back to Angular Observer Directives