
Real‑Time NgRx for Angular 20+: WebSocket Streams, Optimistic Updates, and Typed Actions for Telemetry Dashboards
Practical patterns I use to keep enterprise dashboards snappy under live WebSocket firehose traffic—typed NgRx actions/effects, Signal-powered selectors, and safe optimistic UI.
“Treat your WebSocket as a protocol, not a pipe. Typed actions in NgRx and Signals in the view turn firehoses into calm dashboards.”Back to all posts
I’ve shipped real‑time Angular dashboards for a telecom’s ad analytics platform, an airline’s airport kiosks, and an insurance telematics portal. When the WebSocket starts firehosing 50k events/minute, dashboards jitter, filters desync, and optimistic toggles go rogue. Here’s how I structure NgRx + Signals in Angular 20+ so the UI stays calm and measurable.
As companies plan 2025 Angular roadmaps, hiring managers ask the same thing: can you wire NgRx to WebSockets, prove telemetry is trustworthy, and keep UX silky? Yes—if you treat events as a typed protocol, push effects to the edges, and expose state to the view with Signals.
When NgRx Meets WebSockets: Real‑Time State Without Jitter
A familiar production scene
At a telecom, ad events spiked during primetime and our untyped stream flooded change detection. We stabilized it with typed actions/effects, entity adapters, and signal-driven selectors. Optimistic UI kept operators fast; correlationIds made it safe.
Firehose stream spikes to 50k events/min
Toggle buttons show success before ACK
Filters miss frames and jitter
Why NgRx + Signals for real-time
NgRx gives us deterministic state and time-travelable reducers; Signals give components stable, push-driven inputs. Together, they tame WebSocket noise and make dashboards measurable with Angular DevTools.
Effects isolate IO and concurrency
Reducers stay pure and replayable
selectSignal minimizes renders
Why Angular 20 Dashboards Need Typed, Real‑Time State
Typed protocols prevent chaos
If the wire format isn’t modeled, your store becomes a dumping ground. I use TypeScript discriminated unions and NgRx createActionGroup so every event is explicit and testable.
ActionGroup per channel
No any in reducers
Runtime guards optional
Optimistic UI that doesn’t lie
Operators shouldn’t wait for round‑trip latency to flip a switch. We apply the change locally, tag it with a correlationId, and reconcile on server response—logging time‑to‑ack in GA4 or Firebase for SLOs.
CorrelationId per command
ACK commits, NACK rollbacks
Telemetry on latency and errors
Signals keep renders low
Signals convert selectors into stable, synchronous sources. On a busy telematics board we cut re-renders ~60% by switching high-churn selectors to selectSignal with view-level computed derivatives.
Store.selectSignal for views
computed() for projections
auditTime for heavy lists
NgRx + WebSocket Architecture for Telemetry
1) Define a typed event model
Treat the socket as a protocol. Group actions by direction and event type.
Code: action groups and schema
// telemetry.actions.ts
import { createActionGroup, props, emptyProps } from '@ngrx/store';
export type TelemetryEvent =
| { type: 'device.update'; id: string; status: 'online'|'offline'; ts: number }
| { type: 'metric.append'; id: string; metric: string; value: number; ts: number }
| { type: 'ack'; correlationId: string; ts: number }
| { type: 'nack'; correlationId: string; error: string; ts: number };
export const TelemetryActions = createActionGroup({
source: 'Telemetry',
events: {
'Connect': emptyProps(),
'Connected': emptyProps(),
'Disconnected': props<{ reason?: string }>(),
'Server Event': props<{ event: TelemetryEvent }>(),
'Cmd Toggle Device': props<{ id: string; next: 'online'|'offline'; correlationId: string }>(),
'Cmd Sent': props<{ correlationId: string }>(),
'Apply Device Update': props<{ id: string; status: 'online'|'offline'; optimistic?: boolean }>(),
'Append Metric': props<{ id: string; metric: string; value: number; ts: number }>(),
'Ack Received': props<{ correlationId: string }>(),
'Nack Received': props<{ correlationId: string; error: string }>(),
}
});2) A resilient WebSocket service
// telemetry.socket.ts
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { retryBackoff } from 'backoff-rxjs';
import { filter, map, shareReplay, tap } from 'rxjs/operators';
import { Observable, timer } from 'rxjs';
export interface Wire<T> { type: string; payload: T; }
export class TelemetrySocket {
private socket$!: WebSocketSubject<unknown>;
connect(url: string) {
this.socket$ = webSocket({ url, deserializer: e => JSON.parse(e.data) });
const heartbeats$ = timer(0, 15000).pipe(tap(() => this.socket$.next({ type: 'ping' })));
const stream$ = this.socket$.pipe(
retryBackoff({ initialInterval: 1000, maxInterval: 15000, jitter: 0.3 }),
shareReplay({ bufferSize: 1, refCount: true })
);
return { stream$, heartbeats$ } as const;
}
send<T>(message: Wire<T>) { this.socket$.next(message); }
}Backoff with jitter
Heartbeat/keepalive
ShareReplay for late subscribers
3) Effects: connect, map, and commit
// telemetry.effects.ts
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TelemetryActions } from './telemetry.actions';
import { TelemetrySocket } from './telemetry.socket';
import { map, tap, filter, merge, switchMap } from 'rxjs/operators';
import { of, merge as rxMerge } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class TelemetryEffects {
private actions$ = inject(Actions);
private socket = inject(TelemetrySocket);
private store = inject(Store);
connect$ = createEffect(() => this.actions$.pipe(
ofType(TelemetryActions.connect),
switchMap(() => {
const { stream$, heartbeats$ } = this.socket.connect('/ws/telemetry');
return rxMerge(
heartbeats$.pipe(map(() => ({ type: '[noop] hb' } as any))),
stream$.pipe(map((wire: any) => TelemetryActions.serverEvent({ event: wire as any })))
);
})
));
routeServerEvents$ = createEffect(() => this.actions$.pipe(
ofType(TelemetryActions.serverEvent),
map(({ event }) => {
switch (event.type) {
case 'device.update':
return TelemetryActions.applyDeviceUpdate({ id: event.id, status: event.status });
case 'metric.append':
return TelemetryActions.appendMetric({ id: event.id, metric: event.metric, value: event.value, ts: event.ts });
case 'ack':
return TelemetryActions.ackReceived({ correlationId: event.correlationId });
case 'nack':
return TelemetryActions.nackReceived({ correlationId: event.correlationId, error: event.error });
}
})
));
sendToggle$ = createEffect(() => this.actions$.pipe(
ofType(TelemetryActions.cmdToggleDevice),
tap(({ id, next, correlationId }) => this.socket.send({ type: 'cmd.toggle', payload: { id, next, correlationId } })),
map(({ correlationId, id, next }) => TelemetryActions.applyDeviceUpdate({ id, status: next, optimistic: true }))
));
}Edge-IO in effects
Map server events to actions
Emit ACK/NACK outcomes
4) Reducer and entity adapter
// telemetry.reducer.ts
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { createReducer, on } from '@ngrx/store';
import { TelemetryActions } from './telemetry.actions';
type Device = { id: string; status: 'online'|'offline'; pending?: string | null };
const adapter = createEntityAdapter<Device>();
export interface State extends EntityState<Device> { pending: Record<string, { id: string; prev: 'online'|'offline' }>; }
const initial: State = { ...adapter.getInitialState(), pending: {} };
export const reducer = createReducer(initial,
on(TelemetryActions.applyDeviceUpdate, (state, { id, status, optimistic }) => {
const prev = state.entities[id]?.status ?? 'offline';
const change = adapter.upsertOne({ id, status }, state);
return optimistic ? { ...change, pending: { ...change.pending, [id]: { id, prev } } } : change;
}),
on(TelemetryActions.nackReceived, (state, { correlationId }) => {
// correlate to id as needed (store map correlationId → id)
// simplified example: rollback all pending
let next = state;
for (const pid of Object.keys(state.pending)) {
const prev = state.pending[pid].prev;
next = adapter.updateOne({ id: pid, changes: { status: prev } }, next);
}
return { ...next, pending: {} };
}),
on(TelemetryActions.ackReceived, (state) => ({ ...state, pending: {} }))
);
export const { selectAll, selectEntities } = adapter.getSelectors();5) Selectors with selectSignal
// telemetry.selectors.ts
import { createSelector, createFeatureSelector } from '@ngrx/store';
import { computed, inject, signal } from '@angular/core';
import { Store } from '@ngrx/store';
import { State } from './telemetry.reducer';
export const selectFeature = createFeatureSelector<State>('telemetry');
export const selectDevices = createSelector(selectFeature, s => Object.values(s.entities));
export function useTelemetrySignals() {
const store = inject(Store);
const devicesSig = store.selectSignal(selectDevices);
const onlineCount = computed(() => devicesSig().filter(d => d?.status === 'online').length);
return { devicesSig, onlineCount } as const;
}6) Component wiring (Signals in the view)
// devices.component.ts
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { TelemetryActions } from '../state/telemetry.actions';
import { useTelemetrySignals } from '../state/telemetry.selectors';
@Component({ selector: 'ux-devices', templateUrl: './devices.component.html', standalone: true })
export class DevicesComponent {
private store = inject(Store);
sig = useTelemetrySignals();
connect() { this.store.dispatch(TelemetryActions.connect()); }
toggle(id: string, next: 'online'|'offline') {
const correlationId = crypto.randomUUID();
this.store.dispatch(TelemetryActions.cmdToggleDevice({ id, next, correlationId }));
}
}<!-- devices.component.html -->
<button pButton (click)="connect()" label="Connect" icon="pi pi-wifi"></button>
<p>Online: {{ sig.onlineCount() }}</p>
<ul>
<li *ngFor="let d of sig.devicesSig()">
<span [class.online]="d?.status==='online'">{{ d?.id }}</span>
<button (click)="toggle(d!.id, d!.status==='online' ? 'offline' : 'online')">Toggle</button>
</li>
</ul>7) Backpressure and render control
For firehose metrics, buffer events in effects and reduce in batches. In views, virtualize with cdk-virtual-scroll or PrimeNG TurboTable. I’ve hit 60fps on 20k-row tables by combining Signals with virtualization and batched entity updates.
Use auditTime(16) or rAF for 60fps
Batch metric.append in reducers
Virtualize long lists
End‑to‑End Example: Device Toggle with Optimistic UI and ACK/NACK
What happens on click
This pattern kept airline kiosks responsive even when the network was spotty. We tracked time-to-ack per device and alerted if latency exceeded SLOs.
Dispatch cmdToggleDevice
Effect sends WS and applies optimistic state
ACK commits, NACK rolls back
Telemetry and metrics
At AngularUX I pipe ack latency into GA4 (BigQuery export) to analyze outliers by tenant and region. On one insurance telematics board, this surfaced a 2.3% NACK spike tied to a bad firmware rollout.
Angular DevTools render counts
GA4 custom metrics for ack latency
Firebase Logs for NACK reasons
CI guardrails (Nx + Cypress)
# .github/workflows/ci.yml
name: ci
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx nx run-many -t test --parallel=3
- run: npx nx e2e dashboard-e2e --configuration=ci
# In Cypress, mock WS ACK/NACKs and spike traffic to catch jitterHow an Angular Consultant Approaches Signals + NgRx for Live Dashboards
Playbook I use on rescues
When I’m brought in to rescue a chaotic codebase, I map the event path and measure. We stabilize reducers, add typed actions, convert key selectors to selectSignal, and land feature flags to swap protocols without a deploy freeze.
Trace WebSocket → effect → reducer → selector → Signals
Instrument before/after renders and INP
Add feature flags for protocol changes
Tech I reach for
I’ve delivered this stack across Fortune 100 teams—multi‑tenant role‑based portals, real‑time analytics, and offline‑tolerant kiosk flows with Docker hardware simulation. If you need to hire an Angular developer who has done this under pressure, let’s talk.
Angular 20, NgRx, PrimeNG/Material
D3/Highcharts for viz
Firebase or Node/.NET backends
Key Takeaways and Next Steps
Practical guardrails
Measure with Angular DevTools, GA4/BigQuery, and CI. When we implemented this at a telecom, render counts dropped ~60% and operator INP stayed <100ms during peak traffic.
Typed protocol + action groups
Effects isolate IO; reducers stay pure
selectSignal everywhere in views
Optimistic commands with correlationId
Batch and virtualize high-churn data
What to instrument next
Hook these into Firebase Analytics or your telemetry lake. Use feature flags to iterate safely.
ACK latency percentiles per tenant
Dropped frame rate on heavy lists
Retry/jitter impact on connect times
When to Hire an Angular Developer for Real‑Time Dashboards
Good moments to bring me in
I can audit your pipeline in a week and ship a hardened pattern in 2–4 weeks. If your team needs an Angular consultant with Fortune 100 real‑time experience, I’m available for remote engagements.
You’re adding WebSocket streams and fear jitter
You need NgRx/Signals expertise to stabilize prod
You’re upgrading to Angular 20+ with live data
Key takeaways
- Model your WebSocket protocol with a typed event schema and action groups—no any, no guesswork.
- Drive live data with NgRx effects and expose it via selectSignal to cut renders and jitter.
- Use correlationIds for optimistic updates; rollback on NACK while logging metrics.
- Keep reducers pure and idempotent; use entity adapters for 10k+ item lists.
- Instrument flame charts and render counts; set CI guardrails with mocked WS and flaky network tests.
Implementation checklist
- Create a typed event schema and action groups for all server → client and client → server messages.
- Centralize WebSocket connection with a service that handles heartbeat, backoff, and replay.
- Use NgRx effects to map incoming events to actions; keep reducers pure and idempotent.
- Expose selectors with selectSignal and computed() for stable, low-render views.
- Implement optimistic commands with correlationId + ACK/NACK handling and rollback.
- Batch high-frequency updates; throttle render with requestAnimationFrame or auditTime.
- Add Angular DevTools metrics, GA4 custom events, and CI tests with WS mocks.
Questions we hear from teams
- How much does it cost to hire an Angular developer for a real-time dashboard?
- Most teams start with a 2–4 week engagement for $12k–$30k depending on scope (audit, NgRx setup, Signals migration, CI). I also do fixed-price upgrades and rescue sprints for legacy apps.
- How long does an Angular upgrade or NgRx refactor take?
- Small features or refactors: 1–2 weeks. Full NgRx + Signals adoption with WebSocket streams: 3–6 weeks. Complex multi-tenant or kiosk scenarios can run 6–10 weeks with staged rollout and feature flags.
- What does an Angular consultant do on day one?
- I trace data flow end-to-end, add metrics (render counts, INP), model typed actions, and pilot one live widget using NgRx + selectSignal. You’ll have measurable deltas within the first week.
- Can you integrate Firebase or .NET backends with Angular NgRx?
- Yes. I’ve shipped Firebase (Hosting, Functions, App Check) and .NET or Node gateways with WebSockets, typed event schemas, and CI/CD on Nx monorepos across AWS/Azure/GCP.
- Do we need NgRx if we’re using SignalStore?
- Use SignalStore for local or feature-scoped state and NgRx for cross-app, event-driven workflows and replayable reducers. I often mix both—NgRx for the protocol, Signals for the view and UI state.
Ready to level up your Angular experience?
Let AngularUX review your Signals roadmap, design system, or SSR deployment plan.
NG Wave
Angular Component Library
A comprehensive collection of 110+ animated, interactive, and customizable Angular components. Converted from React Bits with full feature parity, built with Angular Signals, GSAP animations, and Three.js for stunning visual effects.
Explore Components