Architecting RxJS shareReplay for UI Data Caching: Requirements, Design, and Safety Checks
Learn how to apply RxJS shareReplay for caching UI data streams: requirements, minimal design, trust boundaries, operational tests, failure modes, and verification steps.
26 Sept 2025, 22:18 UTC

Requirements
In a UI application we often need a data stream that:
- Emits the latest N values to any new subscriber immediately (so late‑joining components see recent data).
- Shares a single subscription to the upstream source to avoid duplicate work (e.g., multiple HTTP requests).
- Disposes of the upstream subscription when no UI component is listening, to free resources.
These requirements point to a hot, multicast observable with a replay buffer and automatic reference counting.
Smallest suitable design
The simplest expression that satisfies the above is:
import { shareReplay } from 'rxjs/operators';
const data$ = this.http.get<MyModel>('/api/data').pipe(
shareReplay({ bufferSize: 1, refCount: true })
);
shareReplay creates a multicast connection, buffers the last bufferSize emissions, and with refCount: true automatically connects and disconnects the source based on subscriber count.
Trust / data boundaries
The operator assumes the source observable is pure and side‑effect free. All subscribers receive the for each buffered value. Mutating a cached value will affect every other subscriber, which is usually undesirable in UI code where components treat data as immutable.
Operational checks
Unit test (marble diagram)
Using RxJS TestScheduler we can verify both replay behavior and reference‑count disposal:
it('replays latest value and disposes source when no subscribers', () => {
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
scheduler.run(({ cold, expectObservable, expectSubscriptions }) => {
const source = cold('---a|', { a: 42 }); // emits 42 then completes
const result = source.pipe(shareReplay({ bufferSize: 1, refCount: true }));
const sub1 = scheduler.createTime(0);
const sub2 = scheduler.createTime(20);
const unsub1 = scheduler.createTime(40);
expectObservable(result, ' 20ms (a|)').toBe('20ms (a|)');
expectSubscriptions(source.subscriptions).toBe('0 40!'); // source lives until first sub unsubscribes
});
});
Integration check in the browser
Open DevTools → Network, navigate to a component that uses the data$ stream, then navigate away and back quickly. If refCount:true and the interval is within the buffer’s lifetime, you should see only a single XHR request.
Failure modes and design‑change conditions
Error propagation
If the source errors, shareReplay forwards that error to all current and future subscribers and stops replaying further values. To keep the cache alive despite errors, add resetOnError: false:
shareReplay({ bufferSize: 1, refCount: true, resetOnError: false })
Providing a fallback
Alternatively, catch the error and substitute a default stream:
const data$ = this.http.get<MyModel>('/api/data').pipe(
catchError(() => of(defaultData)),
shareReplay({ bufferSize: 1, refCount: true })
);
Limitations and practical verification
Mutable cached values. Never push an object that UI code might mutate; treat emissions as immutable or clone before use.
Buffer size. A large bufferSize retains more memory than needed and can cause stale data if the source completes or errors. Choose the smallest N that satisfies UI requirements.
Stale data. With refCount:true the source resubscribes when a new subscriber appears after a gap. If the source is a cold observable that repeats work (e.g., HTTP), you may see a new request after all subscribers have unsubscribed. Verify by checking network requests as described above.
To confirm the design works in your codebase, run the marble test suite for the unit‑level behavior and perform the browser network check for the integration‑level behavior. Both give concrete, observable evidence without relying on unverified claims.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.