LEARN · DEBUGGING GUIDE

Angular Change Detection Not Updating View: Debugging Guide

When the view doesn't update despite data changes, the root cause is almost always Angular not knowing about the change. This guide covers the three main failure modes: zone.js patching gaps, OnPush strategy misconfiguration, and async operations outside Angular's zone.

IntermediateAngular7 min read

What this usually means

Angular's change detection relies on zone.js to intercept asynchronous operations (HTTP, timers, events) and trigger a change detection cycle. When the view doesn't update, it means either the async operation is running outside Angular's zone (zone.js didn't monkey-patch it), the component uses OnPush strategy and no input reference changed or no event originated from within the component, or the change detection cycle is being suppressed (e.g., running in NgZone.runOutsideAngular). In rare cases, the issue is a missing ChangeDetectorRef.detectChanges() call in an OnPush component after an async operation that Angular cannot track.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1console.log the property before and after the mutation — verify the data actually changes.
  • 2Toggle Angular DevTools 'Highlight Changes' and interact with the app — see if Angular detects any changes.
  • 3Wrap the mutation in this.ngZone.run(() => { /* mutation */ }) — if the view updates, the operation was outside Angular's zone.
  • 4Check the component's changeDetection strategy in the decorator — if it's ChangeDetectionStrategy.OnPush, check if inputs are immutable objects or if you're calling markForCheck.
  • 5In the browser console, run ng.profiler.timeChangeDetection() — if it returns 0 ms or very low, change detection may not be running at all.
  • 6Search the codebase for .runOutsideAngular — if present, verify the callback re-enters the zone correctly.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchComponent decorator file (look for changeDetection property)
  • searchAny file using setTimeout, setInterval, addEventListener, or third-party libraries (e.g., D3, Google Maps, WebSocket onmessage)
  • searchService files with HTTP calls or async pipes in templates
  • searchThe main.ts file (check for disableZone.js or custom zone configuration)
  • searchAngular DevTools' profiler tab (check change detection triggers)
  • searchPackage.json for zone.js version (known issues with 0.8.26-0.8.28)
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningAsync operation (setTimeout, Promise.then, fetch, WebSocket) outside NgZone — zone.js did not patch it
  • warningOnPush component with mutable input objects that are mutated instead of replaced
  • warningThird-party library that runs its own async loop (e.g., animation frame) without triggering change detection
  • warningMissing ChangeDetectorRef.detectChanges() after an async operation in an OnPush component
  • warningZone.js not loaded or partially loaded (e.g., zone.js/dist/zone.js not imported in polyfills)
  • warningUsing Renderer2 or DomSanitizer without triggering change detection
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildWrap the async callback in NgZone.run() to re-enter Angular's zone
  • buildReplace mutable object updates with immutable reassignments (spread operator or Object.assign) in OnPush components
  • buildCall ChangeDetectorRef.markForCheck() in OnPush components after async updates that originate from outside (e.g., route resolver, service subscription)
  • buildUse Angular's async pipe in templates — it automatically triggers change detection when the observable emits
  • buildImport zone.js/dist/zone.js in polyfills.ts and ensure it's not disabled in angular.json
  • buildFor third-party libraries that run outside zone, patch them using NgZone.run() or monkey-patch their async methods
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedAfter applying the fix, use Angular DevTools to verify that change detection runs when the async operation completes (look for increased counter in profiler)
  • verifiedAdd a console.log inside the component's ngDoCheck() — if it fires after the mutation, change detection is running
  • verifiedToggle 'Highlight Changes' in DevTools and confirm the component flashes when data updates
  • verifiedWrite a unit test that subscribes to an async operation and checks that the view reflects the new data — use fixture.detectChanges() or tick()
  • verifiedUse the Angular DevTools profiler to record a session and confirm change detection cycles are occurring at the expected frequency
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningCalling detectChanges() inside ngAfterViewChecked — can cause infinite loop and 'ExpressionChangedAfterItHasBeenCheckedError'
  • warningUsing ChangeDetectorRef.detectChanges() when markForCheck() is sufficient — detectChanges forces a check on the component and its children, which can be expensive
  • warningAssuming zone.js patches all async APIs — it does not patch native fetch, addEventListener on non-window objects, or certain WebSocket implementations
  • warningForgetting to import zone.js in polyfills when using Ivy or custom builds
  • warningTrying to mutate input objects in OnPush components — always create new references
  • warningUsing setTimeout with 0 delay as a workaround — it masks the real problem and can cause timing issues
( 07 )War story

Dashboard Widget Stuck on Loading After WebSocket Update

Senior EngineerAngular 12, RxJS 6, WebSocket (native), Chart.js, Angular DevTools

Timeline

  1. 09:15User reports dashboard shows 'Loading...' even after data arrives.
  2. 09:18I open the network tab — WebSocket messages are received with correct JSON payload.
  3. 09:22I console.log the component property inside the WebSocket onmessage handler — data is there.
  4. 09:25Check component decorator — ChangeDetectionStrategy.OnPush is set.
  5. 09:28I inspect the WebSocket event handler — it's a native addEventListener, not patched by zone.js.
  6. 09:30Wrap the data assignment in this.ngZone.run(() => { ... }) — view updates immediately.
  7. 09:35Refactor to use an RxJS WebSocket subject instead of native WebSocket for better zone integration.
  8. 09:40Deploy fix. User confirms dashboard updates correctly.

The dashboard widget displayed a spinning 'Loading...' indicator even though the WebSocket was delivering data. I first confirmed the network traffic — the data was arriving every 5 seconds. I added a console.log inside the WebSocket's onmessage callback, and the data was clearly being assigned to the component's data property. Yet the view remained stuck.

I immediately suspected the OnPush change detection strategy because the dashboard used OnPush for performance. The WebSocket callback was a native addEventListener, which zone.js doesn't patch by default. Angular had no idea the data changed, so it never ran change detection. I verified by calling this.ngZone.run() around the assignment — the view updated instantly.

The permanent fix was to replace the native WebSocket with an RxJS WebSocket subject, which is zone-aware. I also added a ChangeDetectorRef.markForCheck() in the subscription as a safety net. After deploying, the dashboard updated reliably. The lesson: always ensure async operations outside Angular's core APIs (HTTP, timers) are either wrapped in NgZone.run() or use zone-patched alternatives.

Root cause

Native WebSocket onmessage callback runs outside Angular's zone, combined with OnPush strategy, preventing change detection.

The fix

Replaced native WebSocket with RxJS WebSocket subject and called NgZone.run() in the event handler as a temporary measure.

The lesson

Any async operation outside Angular's patched APIs (setTimeout, HTTP, etc.) must be wrapped in NgZone.run() or the component must use Default change detection. Always prefer zone-aware APIs (RxJS, HttpClient) over native callbacks.

( 08 )How Zone.js Intercepts Async Operations

Zone.js monkey-patches global async APIs like setTimeout, Promise, addEventListener, XMLHttpRequest, etc. When an async operation completes, zone.js triggers Angular's change detection. If you use an API that zone.js doesn't patch (e.g., native WebSocket, IndexedDB, requestAnimationFrame in some cases), Angular won't know the operation finished.

To check if zone.js is patching an API, open the browser console and inspect the function (e.g., window.setTimeout.toString()). If it shows 'function setTimeout() { [native code] }' after zone.js loads, it's not patched. Ensure zone.js is imported before any other scripts.

( 09 )OnPush Strategy and Immutability

OnPush components only run change detection when an input property reference changes (new object), an event originates from the component or its children, or an async pipe emits. If you mutate an object (e.g., this.data.push(item)), Angular sees the same reference and skips the check.

Fix: Always create a new reference. Example: this.data = [...this.data, item] for arrays, or this.data = { ...this.data, newProp: value } for objects. Alternatively, call ChangeDetectorRef.markForCheck() after the mutation if you must mutate.

( 10 )Using ChangeDetectorRef Correctly

markForCheck() tells Angular to check the component and its ancestors in the next change detection cycle. It does not trigger a cycle immediately. detectChanges() runs change detection immediately on the component and its children. Use detectChanges() only when you need immediate view update (e.g., after third-party library callback).

Common mistake: calling detectChanges() in ngAfterViewChecked can cause 'ExpressionChangedAfterItHasBeenCheckedError'. Instead, use markForCheck() in async callbacks and let Angular's built-in cycle handle it.

( 11 )Third-Party Libraries and Zone.js

Libraries like D3, Google Maps, or Chart.js often use requestAnimationFrame or custom event loops. To integrate with Angular, you need to wrap their callbacks in NgZone.run() or use the library's Angular-specific bindings (e.g., angular-google-maps).

Practical approach: Create a service that wraps the library's async methods and exposes observables. Use the async pipe in the template to automatically trigger change detection.

( 12 )Debugging with Angular DevTools

Angular DevTools' 'Profiler' tab records change detection cycles. Click 'Start Recording', trigger the async operation, then stop. If no new cycles appear, change detection is not running. The 'Component Tree' tab shows the component's change detection state (checked or unchecked). Use the 'Highlight Changes' toggle to see which components update on interaction.

To inspect zone boundaries: open DevTools console and type `ng.profiler.timeChangeDetection()`. It returns the time spent in change detection. A very low value (e.g., 0ms) suggests change detection might not be running often enough.

Frequently asked questions

Why does my view update when I use console.log but not otherwise?

console.log is synchronous and doesn't trigger change detection. If you see the data in console but not in the view, it means Angular hasn't run change detection since the data changed. The console.log confirms the data is set correctly, so the issue is Angular not knowing about the change.

Does the async pipe always trigger change detection?

Yes, the async pipe subscribes to the observable and triggers markForCheck() on the component whenever the observable emits a new value. It also handles unsubscription on component destroy. If you're using an observable but the view isn't updating, ensure the observable is emitting in Angular's zone (e.g., using zone.js patched APIs).

What if I can't use NgZone.run() because the library has its own lifecycle?

You can manually call ChangeDetectorRef.detectChanges() after the library's callback. For example, in a D3 zoom handler, call this.cdr.detectChanges() inside the handler. However, this can lead to performance issues if called frequently. A better approach is to use the library's Angular-specific wrapper or create a custom zone that patches the library's async methods.

How do I check if zone.js is properly loaded?

In the browser console, type `window.__zone_symbol__` or `window.Zone`. If it returns a Zone object, zone.js is loaded. You can also check `window.__Zone_disable_*` properties (e.g., `__Zone_disable_XHR`) — if true, that API is not patched. Ensure zone.js is imported in polyfills.ts before any other scripts.

Why does change detection work in development but not in production?

In production mode (enableProdMode()), Angular disables the debug tools and may skip some change detection checks. However, the core change detection behavior is the same. More likely, the production build includes tree-shaking that removes zone.js patches or the third-party library behaves differently. Check if zone.js is imported correctly in the production bundle.