Angular Signals in real-time banking dashboards
bankingOctober 8, 2026

Angular Signals in real-time banking dashboards

Migrating from RxJS without breaking live balances

I've spent most of my career building Angular frontends for banks, and the hardest screens are always the live ones: treasury dashboards, corporate account overviews and payment monitors where a balance changes while the user is looking at it. When we plan a move to Signals on screens like these, the API is the easy part. What takes the work is migrating without a single stale or out-of-order balance reaching the screen. This article walks through how we approach that migration in practice, for an Angular frontend fed by Server-Sent Events from a Kafka-backed balance service. 


Why live balances are harder than most Signals tutorials assume 


Most Signals examples show a counter, a form or a filtered list. A banking dashboard has three properties those examples don't: 

Ordering matters. Two balance updates for the same account can reach the browser out of order after a reconnect. Showing the older one last is a real defect. 

Bursts are normal. End-of-day batch postings, card settlement files and sweeps can produce hundreds of updates per second for one corporate client. 

Every number is money. Floating-point arithmetic, rounding in the view layer and silent truncation are not acceptable anywhere in the pipeline. 

None of these are problems with Signals. But a migration that swaps async pipes for toSignal() one line at a time can expose all three at once. The approach below keeps the RxJS transport layer where it adds value, moves state into signals, and removes Zone.js only at the end. 


The reference architecture 


The setup this article assumes: 

Kafka (balance-events topic, keyed by accountId) 

   â†’ Spring Boot service (consumer + per-customer fan-out) 

   â†’ SSE endpoint  /api/stream/balances 

   â†’ Angular: RxJS at the edge → signal-based store → OnPush components 

Two backend decisions make the frontend migration much simpler, so it is worth agreeing on them with the Java team first. 

Every event has a monotonic version per account. This can be a ledger sequence number or a balance version from the core system. It cannot be the browser's arrival time. 

The SSE id field carries that version, so the browser's built-in reconnect sends Last-Event-ID and the server can resume from the right point: 


 1 @GetMapping(path = "/api/stream/balances", produces = MediaType.TEXT_EVENT_STREAM_VALUE) 
 2 public Flux> balances( 
 3         @AuthenticationPrincipal Jwt jwt, 
 4         @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) { 
 5  
 6     Flux> events = balanceFeed 
 7         .forCustomer(jwt.getSubject(), lastEventId) 
 8         .map(evt -> ServerSentEvent.builder() 
 9             .id(evt.accountId() + ":" + evt.version()) 
10             .event("balance") 
11             .data(evt) 
12             .build()); 
13  
14     Flux> heartbeat = Flux.interval(Duration.ofSeconds(15)) 
15         .map(i -> ServerSentEvent.builder().comment("keep-alive").build()); 
16  
17     return Flux.merge(events, heartbeat); 
18 } 

The heartbeat is a comment line. The browser ignores it, but it stops load balancers and corporate proxies from closing an idle connection. One more banking-specific detail: the native EventSource cannot send an Authorization header. Use same-site session cookies with withCredentials, or use a fetch-based SSE client if your security model requires bearer tokens in the browser. 


Signals hold state; RxJS handles events 


This distinction drives every decision in the migration. 

A signal holds a current value. When it changes several times before Angular renders, consumers see only the latest value, which is exactly what you want for "the available balance of account X". An observable delivers every event over time, with operators for timing, retrying, cancelling and combining. 


So the rule we use is: 

Concern  | Where it lives 

SSE connection, reconnect with backoff, burst batching  | RxJS 

Cancelling in-flight requests when the user switches context (switchMap) | RxJS 

Current balance per account, connection status, selected account | Signals 

Totals, filtered views, "is this tile stale?" | computed() 

Transaction feed (events you must not drop) | RxJS scan → toSignal() 

Teams that try to make everything a signal usually end up rebuilding retry, debounceTime and switchMap badly with effect(). Teams that keep everything in RxJS miss out on fine-grained change detection. The migration moves the boundary; it does not remove one side. 


Step 1: make OnPush universal before touching Signals 

Before converting any state, switch every component on the dashboard to ChangeDetectionStrategy.OnPush and fix what breaks. This step gives you three things: 

It surfaces components that mutate inputs in place or rely on Zone.js to pick up changes made in callbacks. Those components would break silently later, after Zone.js is removed. 

It forms a stable baseline you can profile against. 

It is a low-risk change you can release on its own. 

At the same time, make an inventory of every subscribe() call, async pipe and BehaviorSubject in the dashboard's feature area. Mark each one as either state (a current value) or event stream (each emission matters). That list becomes your migration backlog. 


Step 2: bridge at the service boundary, not in components 

Don't change the transport. Wrap EventSource in an observable once, keep all the stream logic in RxJS, and expose signals from the service. Components never see an observable. 


 1 export interface BalanceEvent { 
 2   accountId: string; 
 3   version: number;          // monotonic per account, from the ledger 
 4   availableMinor: string;   // minor units as a string, e.g. "1250075" = 12,500.75 
 5   currency: string; 
 6   occurredAt: string; 
 7 } 
 8  
 9 export function fromEventSource(url: string, eventName: string): Observable { 
10   return new Observable(subscriber => { 
11     const source = new EventSource(url, { withCredentials: true }); 
12     const onEvent = (e: MessageEvent) => subscriber.next(JSON.parse(e.data) as T); 
13  
14     source.addEventListener(eventName, onEvent); 
15     source.onerror = () => { 
16       // EventSource retries on its own; we only fail when it gives up. 
17       if (source.readyState === EventSource.CLOSED) { 
18         subscriber.error(new Error('Balance stream closed')); 
19       } 
20     }; 
21     return () => source.close(); 
22   }); 
23 } 

 The store service: 

 1 type ConnectionState = 'connecting' | 'live' | 'reconnecting'; 
 2  
 3 @Injectable({ providedIn: 'root' }) 
 4 export class BalanceStore { 
 5   private readonly http = inject(HttpClient); 
 6   private readonly destroyRef = inject(DestroyRef); 
 7  
 8   private readonly _balances = signal>(new Map()); 
 9   private readonly _connection = signal('connecting'); 
10  
11   readonly balances = this._balances.asReadonly(); 
12   readonly connection = this._connection.asReadonly(); 
13  
14   constructor() { 
15     // 1. Load a snapshot so the UI isn't empty while the stream warms up. 
16     this.http.get('/api/balances/snapshot') 
17       .pipe(takeUntilDestroyed(this.destroyRef)) 
18       .subscribe(snapshot => this.apply(snapshot)); 
19  
20     // 2. Apply the live stream on top. 
21     fromEventSource('/api/stream/balances', 'balance').pipe( 
22       tap(() => this._connection.set('live')), 
23       retry({ 
24         delay: (_err, attempt) => { 
25           this._connection.set('reconnecting'); 
26           return timer(Math.min(1000 * 2 ** attempt, 30_000)); 
27         }, 
28       }), 
29       bufferTime(100), 
30       filter(batch => batch.length > 0), 
31       takeUntilDestroyed(this.destroyRef), 
32     ).subscribe(batch => this.apply(batch)); 
33   } 
34  
35   private apply(events: readonly BalanceEvent[]): void { 
36     this._balances.update(current => { 
37       let next: Map | null = null; 
38       for (const evt of events) { 
39         const existing = (next ?? current).get(evt.accountId); 
40         if (existing && evt.version <= existing.version) continue; // stale or duplicate 
41         next ??= new Map(current); 
42         next.set(evt.accountId, evt); 
43       } 
44       return next ?? current; // same reference = no notification, no re-render 
45     }); 
46   } 
47 } 

There is more in these few lines than first appears: 

bufferTime(100) turns a settlement burst into roughly ten state updates per second. Nobody can read a balance that changes faster than that, and it keeps the main thread free for scrolling and input. 

The version check in apply() is the single place where ordering is enforced. It covers reconnect replays, duplicates and the race between the snapshot response and the first stream events. Whichever arrives first, the higher version wins. 

Returning current unchanged when nothing new arrived matters. Signals compare with Object.is, so returning the same reference means dependent computed()s and templates do no work. 

A new Map on every real change, never current.set(...). Mutating the existing map in place is the most common Signals bug we see in reviews: the data changes, but nothing re-renders. 


Step 3: keep money out of floating point, all the way to the template 

Balances arrive as minor units in a string, and they stay that way until the moment they are formatted. Derived totals use BigInt, which is exact: 

 1 readonly totalsByCurrency = computed(() => { 
 2   const totals = new Map(); 
 3   for (const b of this.balances().values()) { 
 4     totals.set(b.currency, (totals.get(b.currency) ?? 0n) + BigInt(b.availableMinor)); 
 5   } 
 6   return totals; 
 7 }); 

Formatting happens in one pure pipe that knows each currency's minor-unit exponent (2 for EUR and RON, 0 for JPY, 3 for KWD). Since computed() is memoised, the total is recalculated only when a balance actually changes, not on every change detection cycle, as a template method call would be. 


Step 4: move derived and UI state to computed() and linkedSignal() 

With the store in place, components become thin: 

 1 @Component({ 
 2   selector: 'ob-account-tile', 
 3   changeDetection: ChangeDetectionStrategy.OnPush, 
 4   imports: [MoneyPipe], 
 5   template: ` 
 6     @if (balance(); as b) { 
 7       {{ b.availableMinor | money: b.currency }} 
 8       @if (stale()) { Reconnecting… } 
 9     } 
10   `, 
11 }) 
12 export class AccountTileComponent { 
13   private readonly store = inject(BalanceStore); 
14  
15   readonly accountId = input.required(); 
16   readonly balance = computed(() => this.store.balances().get(this.accountId())); 
17   readonly stale = computed(() => this.store.connection() !== 'live'); 
18 } 

The stale badge is worth building in from day one. When the stream is reconnecting, the user should see that the figure on screen may be behind. In a treasury context this is part of the product requirement, not a cosmetic detail. 

For state that depends on something else but can also be set by the user, linkedSignal() replaces a whole category of combineLatest + BehaviorSubject code. A typical example is the selected account, which should survive a list refresh as long as the account still exists: 


 1 readonly selectedAccountId = linkedSignal({ 
 2   source: this.accounts, 
 3   computation: (accounts, previous) => 
 4     previous && accounts.some(a => a.id === previous.value) 
 5       ? previous.value 
 6       : accounts[0]?.id ?? null, 
 7 }); 

 The user can call selectedAccountId.set(id). When accounts changes, the selection is either kept or reset in a predictable way, with no subscription to manage. 

What we deliberately don't do is use effect() to copy one signal into another. If you find yourself writing effect(() => this.b.set(f(this.a()))), that should be a computed() or a linkedSignal(). Reserve effect() for genuine side effects that leave Angular: analytics, writing to localStorage, or calling an imperative charting library. 


Step 5: the transaction feed stays a stream 

The transaction list is the exception to "state lives in signals". Each transaction is an event, and dropping intermediate values (which is what a signal does by design) would lose rows. So the accumulation stays in RxJS, and only the result becomes a signal: 

 1 readonly recentTransactions = toSignal( 
 2   this.transactionEvents$.pipe( 
 3     scan((acc, tx) => [tx, ...acc].slice(0, 200), [] as Transaction[]), 
 4   ), 
 5   { initialValue: [] as Transaction[] }, 
 6 ); 

 The bounded window (200 rows here) is intentional. Unbounded arrays in a dashboard that stays open for a full trading day are a slow memory leak. Older history comes from a paginated HTTP call, not from the stream. 


Step 6: Going Zoneless 

Only once every component is OnPush and reads its state from signals do we remove Zone.js. Zoneless change detection has been stable since Angular 20.2 and is the default for new projects from Angular 21. For an existing app, enabling it is one provider: 

 1 bootstrapApplication(AppComponent, { 
 2   providers: [ 
 3     provideZonelessChangeDetection(), 
 4     provideHttpClient(), 
 5   ], 
 6 }); 

Then remove zone.js from the polyfills array in angular.json. 

For a real-time dashboard, the gain is concrete. With Zone.js, every setInterval tick (including the one inside bufferTime), every SSE message and every heartbeat triggers an app-wide change detection pass. Without it, Angular schedules a render only when a signal that a template reads has changed, and it combines multiple signal writes in the same task into one pass. On our own dashboards, profiling after the switch showed far fewer change detection runs during settlement bursts. Measure on your own screens, because the size of the gain depends on how much of the app was already OnPush. 


What to check before you release: 

Third-party widgets. Charting and grid libraries that update the DOM from their own callbacks may have relied on Zone.js to trigger a render. Either feed them from an effect() or write their output into a signal. 

Remaining markForCheck() calls usually point to state that is not in a signal yet. Treat each one as a backlog item rather than a permanent fix. 

setTimeout workarounds for ExpressionChangedAfterItHasBeenChecked errors. These tend to hide data-flow problems that zoneless mode makes visible. 


Testing the migration 


Two layers of tests carry most of the confidence. 

1. Store-level unit tests feed batches of BalanceEvents into the store, including out-of-order, duplicate and snapshot-after-stream sequences, and assert the final map. These tests need no TestBed and run in milliseconds. They are also where your QA colleagues can add the edge cases they have seen in production incidents at other clients. 

2. Component tests in zoneless mode use provideZonelessChangeDetection() in TestBed and await fixture.whenStable() instead of relying on Zone.js to flush. Tests built on fakeAsync/tick need to be reviewed. Many can be rewritten more simply by setting signal values directly. 

Run the old and new dashboards side by side behind a feature flag for at least one month-end close. Comparing rendered balances from both versions against the ledger during a real settlement peak is the most convincing evidence you can give a risk or operations stakeholder. 


Where RxJS still makes sense after the migration 

After the migration, the dashboard still uses RxJS, in a few clearly bounded places: 

  • Transport and resilience: the SSE wrapper, retry with backoff and heartbeat timeouts. 
  • Time-based operators: bufferTime, debounceTime on search and filter inputs, throttleTime on export buttons. 
  • Cancellation: switchMap when the user changes the selected entity and earlier requests must be dropped. 
  • Event accumulation: scan for feeds where every item matters. 

Everything else, including current values, derived figures, UI state and connection status, lives in signals. The result is a codebase where a new developer can tell from a type alone whether something is a value or a stream. Reviews also get shorter, because whole categories of bugs (forgotten unsubscribes, combineLatest glitches, rendering in the wrong zone) no longer have anywhere to occur. 


Closing thought 


A Signals migration on a live banking screen pays off most when it is treated as a data-correctness project first and a framework upgrade second. Decide the versioning contract with the backend, enforce ordering in a single place, keep money exact and bring state into signals in stages. Removing Zone.js then becomes the final step instead of the risky first one.