An RxJS dashboard stream built as combineLatest([user$, settings$, permissions$]) never produces a value; how do you find the cause and fix it?
answer
- which slot is still empty
- trace every source separately
- completed empty means never
- seed optional sources deliberately
- errors look like silence
basics
~20 scombineLatest stays silent until every source has emitted once, so one of the three never emitted, completed empty or errored; trace each source with tap, then seed or default the culprit or fix its producer.
solid answer
~40 s`combineLatest` emits nothing until **every** source has emitted at least once, and it never fills a slot with `undefined`. So 'renders nothing' means one source never emitted, completed **without** emitting (then the join can never emit), or errored into a handler nobody logs. I'd pipe each source through a labelled `tap({ next, error, complete })` to see which one is silent. Then fix by cause: a plain `Subject` that emitted before the subscription should hold state in a `BehaviorSubject`; an optional or slow source can get `startWith(placeholder)` so the dashboard renders partially; a source that may complete empty gets `defaultIfEmpty(fallback)`. Swapping to `forkJoin` makes it worse — it waits for completion, and user or settings streams never complete.
code
ts · 16 linesimport { Observable, combineLatest, defaultIfEmpty, startWith } from 'rxjs';
interface User { id: string; name: string }
interface Settings { theme: 'light' | 'dark' }
declare const user$: Observable<User>; // replays current user
declare const settings$: Observable<Settings>; // may complete empty
declare const permissions$: Observable<string[]>; // slow
const DEFAULT_SETTINGS: Settings = { theme: 'light' };
const dashboard$ = combineLatest({
user: user$,
settings: settings$.pipe(defaultIfEmpty(DEFAULT_SETTINGS)),
permissions: permissions$.pipe(startWith(null as string[] | null)), // null = still loading
});go deeper
Recall that combineLatest waits for a first value from every source, so one silent source means no output at all.
Explain the four ways a source leaves its slot empty — not yet emitted, emitted before subscription, completed empty, errored — and which operator addresses each.
Show the diagnosis: trace next, error and complete per source, then fix by cause, and weigh partial rendering with seeded values against waiting for complete data.
Set a rule for view-model streams: state is always replayable, placeholders are explicit loading markers, and dependent data is derived in one place to avoid mixed emissions.
## Why a combineLatest dashboard goes silent A dashboard view model is often built by joining several independent streams — the signed-in **user**, their **settings**, their **permissions** — with `combineLatest`. The join has one strict rule: it emits nothing until **every** source has emitted **at least once**. It never emits a partial array and never fills a missing slot with `undefined`. A dashboard that renders nothing at all is therefore almost always one source that has not produced its first value. There are only a few ways that happens: 1. **The source has not emitted yet** — a request that was never triggered, a stream gated by a `filter` that never passes, or a slow backend. 2. **The source emitted before anyone listened** — a plain `Subject` whose `next()` ran before the dashboard subscribed. A `Subject` does not replay, so that value is gone. 3. **The source completed without emitting** — for example an error handler that swallowed a failure by returning `EMPTY`. Its slot can now never be filled, so `combineLatest` can never emit. It also does not complete until the other sources complete, so the output just stays quiet. 4. **The source errored** — the error goes straight to the subscriber's error callback and ends the whole stream. If nothing logs that callback, the failure looks exactly like silence. ## Finding the culprit Trace every source separately, including the `error` and `complete` notifications that are easy to forget: ```ts import { Observable, combineLatest, tap } from 'rxjs'; interface User { id: string; name: string } interface Settings { theme: 'light' | 'dark' } declare const user$: Observable<User>; declare const settings$: Observable<Settings>; declare const permissions$: Observable<string[]>; function trace<T>(name: string) { return tap<T>({ next: (v) => console.log(name, 'next', v), error: (e) => console.log(name, 'error', e), complete: () => console.log(name, 'complete'), }); } const dashboard$ = combineLatest({ user: user$.pipe(trace('user')), settings: settings$.pipe(trace('settings')), permissions: permissions$.pipe(trace('permissions')), }); ``` Reading the log tells you which case you are in: | Log for the silent source | Meaning | Usual fix | |---|---|---| | nothing at all | never emitted, or emitted before subscription | fix the trigger; hold state in a `BehaviorSubject` | | `complete` with no `next` | completed empty | give it a default value, or stop swallowing into `EMPTY` | | `error` | the whole join failed | handle the error on that source with a fallback value | | `next`, but only much later | slow source | seed it so the rest can render | ## Fixing it deliberately - **Hold state in something that always has a value.** Current user and settings are *state*, not events. A `BehaviorSubject` (or any source that replays its latest value) emits on subscription, so its slot fills immediately no matter when the dashboard subscribes. - **Seed optional or slow sources with `startWith`.** `permissions$.pipe(startWith(null as string[] | null))` lets the dashboard render at once, with `null` meaning "still loading", then update. The cost is real: the first emission carries placeholder data, the view must treat that marker as "loading" rather than "denied" (an empty array would be indistinguishable from "no permissions"), and there is one more emission to render. - **Default a source that can complete empty.** `defaultIfEmpty(fallback)` emits the fallback only if the source completes without a value, so the slot is filled and the join can proceed. It does nothing for a source that is merely slow — that is `startWith`'s job. - **Recover from errors per source with a value, not `EMPTY`.** Returning `EMPTY` from an error handler turns an error into case 3 — silence. ## Two tempting fixes that are wrong - **Switching to `forkJoin`.** `forkJoin` waits for every source to **complete**. User and settings streams are long-lived and never complete, so the dashboard would stay empty forever — and even if they did complete, it would stop reacting to later changes. - **`startWith(null)` on everything, with no other change.** It makes the join emit, but now every consumer must handle `null` for every field, and a permissions check that treats `null` as "no access" briefly hides content that the user is allowed to see. ## The opposite symptom: too many emissions When settings and permissions are both **derived** from `user$`, one user change makes each derived source emit separately. `combineLatest` then emits once per arrival — first a new user with the old settings, then with new settings but old permissions — a brief, inconsistent mix. The structural fix is to derive the dependent data **from** each user value in one place and join it there, so that one user change yields one consistent view model.
- Why does the dashboard emit several times with mixed data when the user changes?If settings$ and permissions$ are derived from user$, one user change makes each of them emit separately, and `combineLatest` emits on each arrival: new user with old settings, then new settings with old permissions. Derive the dependent data from each user value in one place and join it there, so one user change produces one consistent emission.
- Why is startWith([]) on permissions$ risky, and what is safer?An empty array is indistinguishable from 'this user has no permissions', so the view may briefly hide or deny things the user is allowed to see. Seed with an explicit loading marker such as `null`, and make the view treat that marker as loading, so placeholder data can never be mistaken for a real answer.
saying these in an interview costs you the question
- combineLatest emits with undefined for a source that has not emitted yet
- When one source completes, combineLatest completes and the dashboard stops updating
- A source that completes without emitting makes combineLatest throw an error
- Switching to forkJoin fixes it, since forkJoin waits for everything
- startWith(null) on every source is free; nothing downstream has to change