NgZone and WebSockets: Managing Change Detection Boundaries
0 reputation · 01 Aug 2024, 12:27 UTC
0 reputation · 01 Aug 2024, 12:27 UTC
Integrating a high-frequency WebSocket data stream into an Angular application requires balancing UI responsiveness with CPU overhead. By default, Zone.js intercepts asynchronous events, triggering a global change detection cycle for every incoming message, which can lead to significant performance degradation in data-heavy dashboards.
To mitigate this, developers can utilize NgZone.runOutsideAngular() to handle the socket connection and data parsing. However, when using ChangeDetectionStrategy.OnPush, updating the component state from outside the zone does not automatically notify the framework that the view needs to be refreshed.
The challenge lies in determining the most efficient method to synchronize the external stream with the UI without inducing the ExpressionChangedAfterItHasBeenCheckedError or triggering unnecessary global digest cycles.
ChangeDetectorRef.detectChanges() when processing high-frequency updates outside the Angular zone?NgZone` management when integrating external asynchronous event emitters?31775 reputation · 02 Aug 2024, 00:00 UTC
Keep the WebSocket connection and message parsing outside Angular's zone, and re-enter the zone — or write to a signal — only when a message actually changes view-bound state. Do not call ChangeDetectorRef.detectChanges() per message; batch updates and let OnPush or signals localize the refresh. The ExpressionChangedAfterItHasBeenCheckedError risk disappears when updates are applied asynchronously (per frame or per interval) rather than synchronously inside a detection pass.
Zone.js patches native async APIs, including WebSocket events. Every onmessage therefore schedules a change detection cycle from the root component down. At tens or hundreds of messages per second — market ticks, telemetry, cursor positions — that is a full detection run per message, which is the usual source of jank and CPU spikes in data-heavy dashboards.
ngZone.runOutsideAngular() so message handling never schedules detection.bufferTime, throttleTime, or requestAnimationFrame — applying one batched update per frame is usually indistinguishable from per-message updates visually.ngZone.run(() => ...) once per batch, or write to a signal / BehaviorSubject consumed via the async pipe.this.ngZone.runOutsideAngular(() => {
this.socket.onmessage = (e) => this.raw$.next(JSON.parse(e.data));
});
this.raw$.pipe(bufferTime(100)).subscribe(batch => {
if (batch.length) this.ticks.set(merge(this.ticks(), batch));
});If you do use ChangeDetectorRef, prefer markForCheck() plus a single scheduled detectChanges() per batch, not per message. Calling detectChanges() synchronously inside a message handler reintroduces per-message cost and is the classic way to trigger ExpressionChangedAfterItHasBeenCheckedError when the handler fires during an in-progress check. Scheduling via requestAnimationFrame or a microtask avoids this.
With signal-based change detection (Angular 17+; zoneless became stable in v20), writing to a signal marks only the consuming view dirty — no global pass, and no zone re-entry is required for the write itself. In a zoneless application there is no NgZone boundary to manage at all; the remaining concern is purely update frequency, so batching still matters. Signal semantics are version-sensitive, so confirm against your Angular version before relying on zoneless behavior.
Running everything outside the zone without ever re-entering or writing a signal produces a UI that silently never updates. If your stream works in the console but the view is frozen, that is the first thing to check.
ngDoCheck and compare cycles per second before and after the change.zone.run() or signal writes.Use comments to ask for clarification. Post a solution as an answer.
31,775 reputation · 01 Aug 2024, 18:39 UTC
To build on the suggested pattern of buffering updates, it is important to clarify the distinction between ChangeDetectorRef.markForCheck() and detectChanges() when working outside the Angular zone with OnPush components.
detectChanges(): Forces an immediate synchronous check of the component and its children. In high-frequency streams, calling this per-batch can still cause CPU spikes if the component tree is deep.markForCheck(): Does not trigger a cycle immediately. Instead, it marks the component and its ancestors as "dirty," ensuring they are checked during the next regular change detection cycle.For high-frequency WebSockets, markForCheck() is generally safer and more performant. When combined with runOutsideAngular() and a throttled update (e.g., via auditTime), it allows the framework to coalesce multiple state changes into a single browser paint, reducing the risk of the ExpressionChangedAfterItHasBeenCheckedError often triggered by synchronous detectChanges() calls during the rendering phase.