skip to content

An Angular service polls feature flags with timer, switchMap and shareReplay(1), and polling continues after every subscribing component is destroyed; why, and what does refCount: true change?

level: seniorimportance: should knowfreq 46%

answer

  1. who owns the source subscription
  2. default of the flag
  3. counting subscribers down to zero
  4. completed sources behave differently

basics

~20 s

shareReplay's refCount defaults to false, so its internal ReplaySubject stays subscribed to the polling source after the last subscriber leaves; shareReplay({ bufferSize: 1, refCount: true }) unsubscribes the source at zero subscribers and restarts it on the next subscription.

solid answer

~40 s

With `shareReplay(1)` the operator subscribes to the source once and keeps that subscription even when its own subscriber count drops to zero, because `refCount` defaults to `false`. A `timer(0, 30_000)` feeding `switchMap(() => http.get(...))` never completes, so in a root service the polling runs for the life of the app, sending requests nobody reads. `shareReplay({ bufferSize: 1, refCount: true })` tears the source down when the count reaches zero; the next subscriber gets a new internal `ReplaySubject` and a new source subscription, so the timer starts again and fetches immediately. The trade-off is re-fetching whenever consumers come and go, for example on route changes. For a single `HttpClient` GET the flag barely matters: once the request completes, `shareReplay` keeps the cached value either way.

code

ts · 13 lines
ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { shareReplay, switchMap, timer } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
  private readonly http = inject(HttpClient);

  readonly flags$ = timer(0, 30_000).pipe(
    switchMap(() => this.http.get<Record<string, boolean>>('/api/flags')),
    shareReplay({ bufferSize: 1, refCount: true }),
  );
}

go deeper

for a junior

Recall that shareReplay has a refCount option and that its default keeps the source running after subscribers leave.

for a middle

Explain the reference count, what happens at zero with each setting, and why a completed HTTP response is cached either way.

for a senior

Diagnose a service stream that outlives its consumers, choose between refCount true, a grace-period share(), or a deliberately permanent connection, and verify it with tap logging.

for a principal

Set a team convention for sharing operators on never-completing sources, weighing restart cost against leaked connections, and make it reviewable.

## The setup A feature-flag service wants fresh flags every 30 seconds and one shared stream for all components: ```ts import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { shareReplay, switchMap, timer } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class FeatureFlagService { private readonly http = inject(HttpClient); readonly flags$ = timer(0, 30_000).pipe( switchMap(() => this.http.get<Record<string, boolean>>('/api/flags')), shareReplay(1), ); } ``` Components read `flags$` with the `async` pipe. When the user navigates away and every such component is destroyed, the network panel still shows a request every 30 seconds. ## Why the source keeps running `shareReplay` sits between one **source** and many **subscribers**. It keeps an internal **reference count**: the number of current subscribers. - The first subscriber makes it subscribe to the source (the connection). - Later subscribers attach to the internal `ReplaySubject`. - When subscribers unsubscribe, the count goes down. What happens at **zero** is controlled by `refCount`. In RxJS 7.8 the default is `false`: the connection to the source is **kept**. The components did unsubscribe correctly (the `async` pipe unsubscribes on destroy), but they unsubscribed from the shared stream, not from the source. The source subscription belongs to `shareReplay`, and with `refCount: false` it never lets go. A `timer` never completes, so in a root service that lives as long as the app, polling never stops. RxJS documents this default deliberately: `shareReplay` is often used to keep an expensive setup alive between subscribers. ## What refCount: true changes ```ts shareReplay({ bufferSize: 1, refCount: true }) ``` 1. When the count drops to zero, the source is **unsubscribed**: the timer stops and an in-flight request is cancelled by `switchMap`'s teardown. 2. The next subscriber gets a **new** internal `ReplaySubject` and a **new** source subscription. 3. `timer(0, ...)` fires immediately, so the new subscriber gets fresh flags rather than the stale buffered value. The cost is re-work: if consumers appear and disappear often (a flag-dependent widget in a route that users flip between), each gap causes a new first request. ## Completed sources are the exception `shareReplay` is built on `share()` with `resetOnComplete: false`, and the refCount reset only applies while the source is still active. | Source | refCount: false | refCount: true | |---|---|---| | Never-completing (timer, WebSocket, a stream derived from a BehaviorSubject) | source runs forever after the last subscriber | source torn down at zero, restarted on next subscribe | | Single HttpClient GET, response already arrived | value cached for the Observable's life | value cached for the Observable's life | | Single HttpClient GET, all subscribers leave before the response | request finishes and the response is cached | request cancelled; next subscriber sends a new one | So "always add `refCount: true`" is not a rule; it matters for sources that stay active. ## Middle ground: a grace period `share()` accepts a function for `resetOnRefCountZero`, which delays the teardown: ```ts share({ connector: () => new ReplaySubject(1), resetOnError: true, resetOnComplete: false, resetOnRefCountZero: () => timer(5_000), }) ``` If a new subscriber arrives within five seconds, for instance the next route's component, the source keeps running and no restart occurs. ## How to diagnose it - Add `tap({ subscribe: ..., finalize: ... })` around the source to log when it is subscribed and torn down. - Look for `shareReplay(1)` or `shareReplay(n)` on any source that does not complete. - Remember that the leak is not only memory: here it is steady network traffic and server load. ## Other sources that hit the same trap The polling timer is the clearest case, but any source that never completes behaves the same way under `shareReplay(n)` with the default setting: - a stream derived from a service's own `BehaviorSubject`, which also keeps whatever it closes over alive, - `Router.events` or a form control's `valueChanges` piped through a shared operator in a service, - a WebSocket or server-sent-events wrapper, - `fromEvent(window, 'resize')` shared across components. For each, ask two questions when reviewing: does this source complete on its own, and should it keep running when nobody is listening? If the answers are "no" and "no", the sharing operator needs `refCount: true` or an equivalent `share()` configuration.

  • The components use the async pipe, which unsubscribes on destroy. Why is that not enough?
    The async pipe unsubscribes from `flags$`, the shared stream. The subscription to the timer belongs to `shareReplay`, and with `refCount: false` it is kept when the subscriber count reaches zero. Component-side teardown is correct; the leak is in the service's sharing policy.
  • Why might a team deliberately keep refCount: false on a long-lived stream?
    When the setup is expensive and consumers come and go constantly, restarting the source each time costs more than keeping it alive. A connection that should run for the whole session anyway is a reasonable fit, as long as that is a conscious choice and not an accident.

shareReplay with refCount false is a radio left on in an empty office: everyone who was listening walked out, but nobody was told to switch it off. refCount true is a motion sensor that turns it off when the last person leaves and on again when someone walks in.

saying these in an interview costs you the question

  • shareReplay(1) unsubscribes from the source once all components are destroyed
  • The async pipe tears down the shared source, so no leak is possible
  • refCount: true makes a completed HttpClient response re-request on each subscribe
  • refCount defaults to true in RxJS 7, so shareReplay(1) never leaks
  • Always add refCount: true; it has no downside