The short answer
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.
Why the default behavior hurts
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.
Recommended pattern
- Subscribe to the socket inside
ngZone.runOutsideAngular() so message handling never schedules detection. - Parse, filter, and buffer messages outside the zone. Cap update frequency with
bufferTime, throttleTime, or requestAnimationFrame — applying one batched update per frame is usually indistinguishable from per-message updates visually. - When a batched result changes view state, either re-enter with
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));
});
About manual detectChanges()
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.
How signals change the picture
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.
Common failure mode
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.
Verification
- Add a temporary counter in the root component's
ngDoCheck and compare cycles per second before and after the change. - Profile with Angular DevTools or the browser Performance panel under a simulated high-rate stream.
- Confirm state-changing updates still reach the view through
zone.run() or signal writes.