
Blend RxJS Streams with Angular 20+ Signals Using Typed Adapters — Keep SSR and Tests Deterministic
A pragmatic pattern I use on enterprise dashboards to make RxJS + Signals play nice with SSR, Firebase Hosting, and tests.
Deterministic SSR isn’t luck—it’s a contract. Typed adapters make RxJS and Signals sign the same one.Back to all posts
If you’ve ever watched an SSR-hydrated dashboard jitter because an Observable re-emits on the client, you know the feeling: the UI snaps from server ‘ready’ to client ‘loading’ to ‘ready’ again. I’ve battled this across airline kiosks (offline-tolerant), telecom analytics (real-time WebSockets), and insurance telematics dashboards. The fix that sticks: typed adapters that normalize RxJS→Signals and Signals→RxJS.
In Angular 20+, Signals are the reactive substrate. RxJS still powers HTTP, WebSockets, and complex operators—but mixing them ad hoc leads to hydration mismatches and flaky tests. Below is the pattern I ship in Nx monorepos, on Firebase Hosting SSR, and inside SignalStore. It’s boring by design—and that’s why it works.
We’ll define a Resource
When Observables Meet SSR, You Get Jitter
As companies plan their 2025 Angular roadmaps, deterministic SSR and tests are table stakes. Product managers notice flicker. Directors notice flaky CI. Typed RxJS↔Signals adapters remove the chaos and make Angular 20+ feel rock solid.
The enterprise smell
On a telecom analytics app I upgraded (Angular 11→20), we saw hydration mismatches anytime a cold HTTP observable executed twice—once on the server, again on the client. The UI flashed loading before settling. WebSockets were worse: client booted before the first tick and stomped SSR-ready content.
The rule now: never wire Signals and RxJS directly in components. Use a typed adapter with a single initialValue and a TransferState seed so both sides agree on the first frame.
Server renders ready state, client replays a different timeline
Template subscribes in multiple places, racing emissions
Tests pass locally but flake in CI
How an Angular Consultant Approaches Signals Migration with Typed Adapters
Don’t rip RxJS out—standardize it. The adapter pattern keeps contracts explicit and reduces one-off interop code sprinkled across your codebase.
Goals
I migrate teams incrementally: keep RxJS for transport and composition, then adapt into Signals at the store boundary. This lets us keep existing NgRx effects or custom services while leaning into Signals for component rendering and memoized computed selectors.
Deterministic first value on both server and client
Uniform error and loading semantics
Isolated side effects, test-friendly
Typed RxJS↔Signals Adapter Pattern for Deterministic SSR and Tests
Here’s a minimal, production-safe adapter I use in Nx workspaces and Firebase SSR apps:
// rx-sig-adapter.ts
import { inject, PLATFORM_ID, Signal, computed } from '@angular/core';
import { isPlatformServer, TransferState, makeStateKey } from '@angular/platform-browser';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { Observable, of } from 'rxjs';
import { catchError, map, startWith, shareReplay, take } from 'rxjs/operators';
export type Resource<T> =
| { state: 'loading'; data?: T }
| { state: 'ready'; data: T }
| { state: 'error'; error: unknown; data?: T };
export interface RxSigAdapter {
toResourceSignal<T>(source$: Observable<T>, opts: { initialValue?: T; transferKey?: string }): Signal<Resource<T>>;
toObservable<T>(sig: Signal<T>): Observable<T>;
}
export function createRxSigAdapter(): RxSigAdapter {
const transfer = inject(TransferState, { optional: true });
const platformId = inject(PLATFORM_ID);
const isServer = isPlatformServer(platformId);
return {
toResourceSignal<T>(source$: Observable<T>, opts) {
const key = opts?.transferKey ? makeStateKey<T>(opts.transferKey) : null;
let initial = opts?.initialValue;
// Client: hydrate from server seed if present
if (key && !isServer && transfer?.hasKey(key)) {
initial = transfer.get(key, initial as T);
transfer.remove(key);
}
const resource$ = source$.pipe(
map((v) => ({ state: 'ready', data: v } as const)),
startWith(
initial !== undefined
? ({ state: 'ready', data: initial } as const)
: ({ state: 'loading' } as const)
),
catchError((error) => of({ state: 'error', error } as const)),
shareReplay({ bufferSize: 1, refCount: false })
);
// Server: write first ready value to TransferState
if (key && isServer && transfer) {
resource$.pipe(take(1)).subscribe((r) => {
if (r.state === 'ready') transfer.set(key, r.data as T);
});
}
return toSignal(resource$, { initialValue: initial !== undefined ? ({ state: 'ready', data: initial } as const) : ({ state: 'loading' } as const) });
},
toObservable<T>(sig: Signal<T>): Observable<T> {
return toObservable(sig);
}
};
}Notes:
- The adapter centralizes SSR seeding and loading semantics.
- It uses shareReplay(1) to avoid multi-subscription jitter.
- Resource
yields explicit template states and fewer if/else branches.
1) Define a Resource type
Wrap data in an explicit state machine. This avoids implicit null checks and makes SSR/CSR parity visible.
2) Build the adapter
Single place to dictate how RxJS timelines become Signals, including error mapping and initial load semantics.
Require initialValue or TransferState seed
shareReplay(1) all sources
Map errors to Resource.error
3) SSR seed with TransferState
This prevents the first client render from regressing into loading.
Write on server, read-and-remove on client
Avoids double-fetch and flicker
SignalStore Example: HTTP + WebSocket Telemetry with Typed Adapters
// customers.store.ts
import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { computed, inject, Signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { filter, map, retryBackoff, takeUntil } from 'rxjs/operators';
import { createRxSigAdapter, Resource } from './rx-sig-adapter';
interface Customer { id: string; name: string; plan: 'free'|'pro'|'enterprise'; }
interface TelemetryEvt { type: 'cpu'|'mem'; host: string; value: number; ts: number; }
export const CustomersStore = signalStore(
withState({}),
withComputed((store) => {
const http = inject(HttpClient);
const adapter = createRxSigAdapter();
const customersRes = adapter.toResourceSignal(
http.get<Customer[]>('/api/customers'),
{ initialValue: [], transferKey: 'CUSTOMERS' }
);
const customers = computed(() => {
const r = customersRes();
return r.state === 'ready' ? r.data : [];
});
return { customersRes, customers } as {
customersRes: Signal<Resource<Customer[]>>;
customers: Signal<Customer[]>;
};
}),
withMethods((store) => {
let socket!: WebSocketSubject<TelemetryEvt>;
const adapter = createRxSigAdapter();
const connectTelemetry = () => {
socket = webSocket<TelemetryEvt>('wss://telemetry.example.com');
const live$ = socket
.pipe(
// example: typed filter
filter((e) => e.type === 'cpu')
);
store['telemetryRes'] = adapter.toResourceSignal(live$.pipe(map(e => [e])), { initialValue: [] });
};
const disconnectTelemetry = () => socket?.complete();
return { connectTelemetry, disconnectTelemetry };
})
);<!-- customers.component.html -->
<p-table *ngIf="store.customersRes().state === 'ready'" [value]="store.customers()">
<ng-template pTemplate="header">
<tr><th>Name</th><th>Plan</th></tr>
</ng-template>
<ng-template pTemplate="body" let-c>
<tr><td>{{ c.name }}</td><td>{{ c.plan }}</td></tr>
</ng-template>
</p-table>
<p *ngIf="store.customersRes().state === 'loading'">Loading…</p>
<p *ngIf="store.customersRes().state === 'error'">Failed to load.</p>This is the exact shape I’ve used on a broadcast media network’s VPS scheduler and a telecom ads analytics platform. Typed adapters made SSR rock solid and stopped the initial-frame flicker in PrimeNG/Material tables.
HTTP data into Resource signals
The store adapts HTTP streams once, exposes memoized selectors as Signals, and keeps side effects in methods.
Typed WebSocket events
For telemetry dashboards, I use typed event unions and exponential retry logic. Adapting the live stream into Signals prevents runaway subscriptions.
Typed schemas prevent UI corruption
Gate connections in SSR
PrimeNG table with zero flicker
PrimeNG’s table renders from Resource.ready only; SSR produces the same first frame as the client.
Deterministic Testing with TestScheduler and Signals
import { TestScheduler } from 'rxjs/testing';
import { map } from 'rxjs/operators';
import { toObservable } from '@angular/core/rxjs-interop';
import { createRxSigAdapter } from './rx-sig-adapter';
it('adapts cold observable into deterministic Resource signal', () => {
const ts = new TestScheduler((a, e) => expect(a).toEqual(e));
ts.run(({ cold, expectObservable }) => {
const src$ = cold('a-b|', { a: 1, b: 2 });
const adapter = createRxSigAdapter();
const resSig = adapter.toResourceSignal(src$, { initialValue: 0 });
const states$ = toObservable(resSig).pipe(map((r) => r.state));
expectObservable(states$).toBe('abc', { a: 'ready', b: 'ready', c: 'ready' });
});
});Tip: when asserting derived signals, use Angular DevTools to confirm only expected recomputations occur—your flame chart should show one initial compute, then one per emission.
Why it matters
Signals are easy to observe via toObservable, which means the RxJS TestScheduler can drive the clock without flakes.
No real timers or setTimeout
Expect exact marble sequences
Test snippet
A minimal example asserting Resource state transitions with marble syntax.
SSR and Firebase Hosting: Prevent Hydration Mismatch
// example resolver for SSR seeding
@Injectable({ providedIn: 'root' })
export class CustomersResolver implements Resolve<boolean> {
private http = inject(HttpClient);
private transfer = inject(TransferState);
resolve(): Observable<boolean> {
return this.http.get<Customer[]>('/api/customers').pipe(
tap((data) => this.transfer.set(makeStateKey('CUSTOMERS'), data)),
map(() => true)
);
}
}With this in place, the adapter reads the seed on the client, so the initial Signal is ready—no flicker.
Metrics to track:
- Hydration warnings: 0 in browser console
- Lighthouse CI: INP p75 stable, avoid long tasks on hydration
- Firebase Performance: First Input Delay consistent between SSR and CSR routes
Guardrails I use in CI/CD
On AngularUX demos, we deploy SSR to Firebase Hosting with Functions. Deterministic adapters let the first client frame match exactly, which shows up as 0 hydration warnings and stable INP.
Lighthouse CI checks for hydration failures
Bundle budgets to keep first paint lean
Firebase Logs to verify first-hit caching
Server seeding pattern
Seed from resolver or APP_INITIALIZER so the first render is ready.
When to Hire an Angular Developer for Legacy Rescue
Need an Angular expert to stabilize RxJS/Signals interop without a rewrite? I’m a remote Angular consultant with Fortune 100 experience—happy to assess your codebase and propose a low-risk plan.
Good candidates
If your app flickers after SSR or tests are flaky, a typed adapter rollout typically takes 1–2 weeks in an Nx monorepo. I’ve done this for airport kiosks (with Docker-based device simulation), insurance telematics dashboards, and multi-tenant admin portals.
AngularJS→Angular migrations with RxJS-heavy services
Angular 9–14 apps adopting Signals + SignalStore
Real-time dashboards with WebSockets and typed events
Practical Takeaways
- Centralize RxJS↔Signals interop in a typed adapter; don’t sprinkle toSignal calls around components.
- Always provide initialValue or a TransferState seed. No undefined first frames.
- Keep side effects in stores; expose only Signals to components.
- Test with TestScheduler via toObservable(sig) for deterministic timelines.
- Track hydration, INP, and recompute counts with Angular DevTools and Lighthouse CI.
- PrimeNG/Material tables render cleanly when bound to Resource.ready—no SSR flicker.
Questions
If you’d like a quick review of your RxJS/Signals interop, or want help instrumenting SSR and tests, let’s talk. I can usually deliver an assessment within a week and start stabilizing within 2–4 weeks depending on scope.
Key takeaways
- Typed RxJS↔Signals adapters give you deterministic SSR and unit tests by standardizing initial values, errors, schedulers, and TransferState seeds.
- Use a Resource<T> shape and a single adapter toSignal/toObservable to eliminate hydration jitter and template flicker.
- Seed server-rendered values with TransferState and require a synchronous initialValue to avoid client/server divergence.
- Keep effects in stores; never subscribe in templates. Use SignalStore to orchestrate HTTP + WebSocket streams safely.
- Test with RxJS TestScheduler and toObservable(sig) for precise, clock-driven expectations—no flakiness.
- Instrument with Angular DevTools, Lighthouse CI, and Firebase Performance to verify 0 hydration mismatches and stable INP.
Implementation checklist
- Define a Resource<T> type with loading/ready/error states
- Create a single RxSigAdapter for toSignal/toObservable
- Always pass initialValue or a TransferState seed
- ShareReplay(1) all observable inputs before toSignal
- Keep side effects in stores; never subscribe in templates
- Use TestScheduler for deterministic tests around signals
- Guard WebSocket adapters behind platform checks for SSR
- Track hydration mismatches and INP in CI with Lighthouse
Questions we hear from teams
- What does a typed RxJS↔Signals adapter solve?
- It standardizes initial values, error handling, and SSR seeding so your first client frame matches the server. You get no hydration flicker, fewer template branches, and reliable tests using RxJS TestScheduler.
- How long does it take to implement this pattern?
- In most enterprise apps, 1–2 weeks to introduce the adapter, retrofit critical flows (HTTP/WebSockets), and add tests. Larger NgRx migrations or SSR adoption may extend to 3–6 weeks.
- Will this work with SignalStore and NgRx?
- Yes. Keep RxJS for effects and transport, convert to Signals at store boundaries, and expose selectors as Signals. I commonly mix NgRx effects with SignalStore for component ergonomics.
- Does this help on Firebase Hosting SSR?
- Absolutely. TransferState seeds from Functions/SSR prevent first-frame regressions, yielding 0 hydration warnings and stable INP in Lighthouse CI and Firebase Performance.
- How much does it cost to hire an Angular developer for this?
- Typical engagements start with a fixed-price assessment, then a short implementation sprint. Contact me for a scope-based estimate; most teams see value within the first two weeks.
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