Understanding change detection is crucial for building performant Angular applications. This module covers how Angular detects changes, optimization strategies, and performance best practices.Change detection is Angular’s mechanism for keeping the screen in sync with your data. Every time something happens — a click, an HTTP response, a timer — Angular needs to check if any data changed and update the DOM accordingly. In a small app, this is imperceptible. In a large app with hundreds of components, inefficient change detection is the number one cause of janky scrolling, slow interactions, and frustrated users. This module teaches you how to make Angular only check what actually changed.What You’ll Learn:
// Components must use signals or manual CD@Component({ selector: 'app-counter', standalone: true, template: ` <button (click)="decrement()">-</button> <span>{{ count() }}</span> <button (click)="increment()">+</button> `})export class CounterComponent { count = signal(0); increment() { this.count.update(c => c + 1); // Triggers CD via signal } decrement() { this.count.update(c => c - 1); }}
Zoneless is experimental as of Angular 18, but it is the direction Angular is heading. The benefit is significant: removing Zone.js eliminates the monkey-patching of every async API (setTimeout, Promise, addEventListener, etc.), which reduces bundle size by ~15KB and avoids unexpected change detection triggered by third-party libraries. The trade-off: you must use signals or manual ChangeDetectorRef calls for all state that affects the template. If you are starting a new project and your team is comfortable with signals, it is worth experimenting with — but have a fallback plan.
// BAD: Function called on every change detection cycle.// If you open DevTools and watch the console, you will see "Called!"// printed dozens of times per second -- even when the name has not// changed. Angular has no way to know the return value is the same// without actually calling the function every time.@Component({ template: ` <div>{{ getFullName() }}</div> <div>{{ calculateTotal() }}</div> `})class BadComponent { getFullName() { console.log('Called!'); // Called many times return `${this.firstName} ${this.lastName}`; }}// GOOD: Use computed signals. The computed value is cached and only// recalculates when its dependencies (firstName or lastName) actually// change. Between changes, Angular reads the cached value -- zero// wasted computation, even during rapid change detection cycles.@Component({ template: ` <div>{{ fullName() }}</div> <div>{{ total() }}</div> `})class GoodComponent { firstName = signal('John'); lastName = signal('Doe'); items = signal<Item[]>([]); fullName = computed(() => `${this.firstName()} ${this.lastName()}`); total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));}
Q: You switch a component to OnPush and the template stops updating when you fetch data from an API. What went wrong, and what are the exact conditions under which OnPush checks a component?
Strong Answer: OnPush means Angular will only check this component when one of these specific things happens: an @Input reference changes (not mutation), an event originates from this component or its children, an async pipe receives a new emission, a signal used in the template changes, or you manually call markForCheck() or detectChanges().The common scenario: you fetch data with subscribe() and assign the result to a plain class property. With Default strategy, this works because Angular checks everything. With OnPush, none of the above triggers fire — the HTTP response is handled in a subscription callback, the property is not a signal, and no event or async pipe is involved. The view is stale.The fixes, ranked from best to worst: use toSignal() to convert the observable to a signal (signals trigger OnPush). Use the async pipe in the template (it calls markForCheck internally). Convert the property to a signal and use signal.set(). Inject ChangeDetectorRef and call markForCheck() in the subscribe callback. The last option is a code smell — if you find yourself calling markForCheck frequently, you are fighting OnPush instead of working with it.The broader lesson: OnPush works beautifully when your data flow is either signal-based or pipe-based. It fights back when you use imperative subscribe-and-assign patterns.Follow-up: What is the difference between markForCheck() and detectChanges()?
Answer: markForCheck() marks the component and all its ancestors as “dirty” — they will be checked on the next change detection cycle (whenever Angular decides to run one). It is safe to call from anywhere. detectChanges() immediately runs change detection synchronously on this component and its children, right now. It is more aggressive and can cause issues if called during an ongoing change detection cycle (ExpressionChangedAfterItHasBeenCheckedError). I use markForCheck 99% of the time; detectChanges only when I need the DOM to update before the next line of code executes, like when measuring element dimensions after a data change.
Q: Explain how signals enable zoneless change detection. What is the fundamental architectural shift, and what breaks when you remove Zone.js?
Strong Answer: Zone.js works by monkey-patching every async API so Angular can detect when async operations complete and trigger change detection globally. It is a blunt instrument — a single setTimeout in a third-party library triggers a full component tree check. Signals offer a surgical alternative: they track exactly which template bindings depend on which data, and only mark those specific components for check when the data changes.With zoneless Angular (provideExperimentalZonelessChangeDetection), Zone.js is not loaded. No monkey-patching occurs. Angular only knows about changes through signals, markForCheck() calls, or event handlers. If you have a plain class property that changes in a setTimeout callback, the template never updates — there is no Zone.js to detect the setTimeout completion.What breaks: any code that relies on automatic change detection after async operations. This includes subscribe-and-assign patterns, setTimeout/setInterval callbacks that modify component state, Promise.then handlers that update data, and third-party libraries that use async APIs internally. All of these need to either use signals, the async pipe, or explicit markForCheck/detectChanges calls.The practical migration path: first, convert all template-bound state to signals. Then switch to OnPush on all components (which is a prerequisite). Then enable zoneless. If your app already uses signals and OnPush everywhere, the zoneless switch is nearly free.Follow-up: What is the performance benefit of removing Zone.js?
Answer: Two benefits. First, bundle size drops by roughly 13-15KB (gzipped) because you no longer ship Zone.js. Second, and more importantly, you eliminate phantom change detection cycles. In a zone-based app, every setTimeout, fetch, and addEventListener from every library triggers change detection. I have profiled apps where third-party charting libraries caused 50+ unnecessary change detection cycles per second. Without Zone.js, those async operations are invisible to Angular unless you explicitly use signals. The result is significantly less work for the framework and smoother 60fps rendering.
Q: You are profiling an Angular app and discover that a parent component's template has method calls like getTotal() and getFilteredItems(). Explain why this is a performance problem and how you would refactor it.
Strong Answer: Method calls in templates are recalculated on every change detection cycle because Angular has no way to know if the return value changed without actually calling the method. If change detection runs 30 times per second (which is normal during user interaction), getTotal() executes 30 times per second even if the underlying data has not changed. If getFilteredItems() iterates a 1,000-item array, you are doing 30,000 array iterations per second for nothing.The refactoring depends on the context. If the method is a pure derivation of other data, replace it with a computed signal. fullName = computed(() => this.firstName() + ’ ’ + this.lastName()) only recalculates when firstName or lastName signals actually change, and the result is cached between reads. If the data comes from observables, use a pure pipe — pipes with pure: true (the default) only re-execute when the input reference changes.For the specific case of filtering: instead of calling a method in the template, I create a filteredItems computed signal that derives from the items signal and the filter signal. The template reads filteredItems() which returns the cached result. When items or filter change, the computed recalculates once and caches again.The key mental model shift: templates should only read values, never compute them. All computation should happen in the component class (via computed signals) or in pure pipes.Follow-up: What about getter properties — are they safer than methods in templates?
Answer: No, getters have the exact same problem. A getter in TypeScript is syntactic sugar for a method call — Angular calls it on every change detection cycle. The only difference is visual: user.fullName looks like a property access but executes code. The fix is the same: replace with a computed signal.