1. RxJS subscription
Did the consumer unsubscribe before the Observable completed?
Frontend interview practice question
Unsubscribing from an active HttpClient Observable tears down the subscription and aborts the client request. That does not prove server work stopped or stale UI is impossible. The lab below shows how to verify each layer.
This Angular interview question tests whether you can separate RxJS teardown, browser transport cancellation, stale UI prevention, and server-side work, then prove the chosen request policy with DevTools and Angular tests.
Use this Angular interview question to rehearse a quick answer, common mistake, follow-up, and production pitfall.
Run a local RxJS model to see which owner unsubscribes, when transport teardown occurs, and where stale UI can still enter the system. The model makes no HTTP request and does not claim that server work stopped.
A canceled subscription is one fact in a chain, not the whole conclusion.
Did the consumer unsubscribe before the Observable completed?
Did Angular’s HTTP backend receive the teardown while the request was active?
Can an older response still write into state after a newer intent?
Did the server stop computation after the client connection closed?
Choose a product contract, inspect its code, then run the subscription lifecycle.
Stop one request owned by an imperative workflow.
Unsubscribing an active HttpClient subscription tears down Angular’s transport. It does not retroactively cancel a response that already completed.
const subscription = this.http.get('/api/profile').subscribe({
next: profile => this.profile.set(profile),
error: error => this.error.set(error)
});
// Run while the request is still active.
subscription.unsubscribe();Closed immediately; teardown runs once.
The active XHR or Fetch request receives an abort signal.
No next/error notification reaches this subscriber after teardown.
The server may continue work unless it observes disconnects cooperatively.
No events yet. Run the model to execute real RxJS subscription and teardown paths.
The active request is normally labeled canceled or aborted.
expect(request.cancelled).toBeTrue()The interactive lab above executes a local RxJS Observable; it is not a live API benchmark. Read its result from left to right. First identify who owns the subscription and which event releases it. Next ask whether teardown reached an HTTP backend while the request was still active. Then verify the product rule independently: can an obsolete intent still commit state? Finally, treat server cancellation as a separate distributed-systems question. A proxy, service worker, cache, interceptor, or server runtime can change what you observe after the client disconnects. This sequence prevents two common reasoning errors: calling a response “canceled” merely because the UI ignored it, and claiming server computation stopped merely because DevTools marked the browser request as canceled.
Select the product contract before choosing an operator, run the deterministic model, and explain every event in its trace. Then reproduce the same boundary in your application with three correlated signals: a request ID in DevTools, a teardown or finalize() log, and an assertion about the rendered state. Use network throttling to keep the request active long enough to exercise cancellation. Repeat the check after interceptors and sharing operators are installed, because they can move upstream ownership away from the component that unsubscribed. The goal is not to collect one red “canceled” row; it is to show that the intended owner released its work and that an obsolete response cannot win the UI.
Keep the returned Subscription when the product has a real Cancel action, such as stopping a report preview before it finishes. Calling unsubscribe() while the HTTP request is active closes the RxJS subscription and asks Angular's backend to abort the transport. Calling it after the response completed is harmless but cannot undo work already finished. A cancel button also needs a UI contract: disable repeated cancellation, leave the screen in a stable idle or canceled state, and decide whether a later retry starts from scratch.
For typeahead, route parameters, filters, and other latest-only reads, switchMap replaces the current inner subscription when a new outer value arrives. With a cold HttpClient Observable, that replacement tears down the old request. The important guarantee is ownership: query B replaces query A, so A must not update B's screen. Debounce and distinctness reduce unnecessary work, but neither is the cancellation mechanism. Do not use switchMap for a payment, save, or audit write that must finish; a second emission would unsubscribe the first.
mergeMap subscribes to every inner request and allows responses to complete in any order. That is useful for independent parallel work, but dangerous for a search result assigned directly to one state field: a slow response for A can arrive after B and overwrite the newer UI. Dropping A inside the subscriber can protect the UI if the generation check is correct, but the transport still ran. Choose mergeMap only when concurrent work is part of the UX contract, and add bounded concurrency, deduplication, and idempotency where the workload requires them.
Use takeUntilDestroyed() for an imperative subscription whose lifetime belongs to an Angular component, directive, or service injection context. Destruction completes the operator's notifier path and unsubscribes upstream, so an active HttpClient request is aborted. It prevents work from continuing solely because a destroyed view still owns a subscription. It does not decide what should happen when the component remains alive but a newer query arrives; combine lifecycle ownership with switchMap or another intent policy when both boundaries matter.
AsyncPipe subscribes while its view consumes the Observable and unsubscribes when the pipe is destroyed or its Observable reference changes. If the source is a still-active HttpClient request, that teardown can abort it. The pipe is not automatically a latest-only operator for arbitrary requests you manually start elsewhere; it owns only its subscription. Prefer an Observable view model rendered with AsyncPipe when the template should own the lifetime, and keep request selection in the upstream pipeline.
Sharing changes who owns the upstream subscription. With shareReplay({ bufferSize: 1, refCount: true }), the shared source unsubscribes while it is still active when the last consumer leaves, so an in-flight HTTP request can be aborted. With refCount: false, the source remains subscribed after downstream consumers leave and an active request can continue. Once a one-shot HTTP request has completed and its value is cached, later unsubscription cannot cancel that completed transport. Define cache lifetime, invalidation, error reset, and refresh semantics instead of treating shareReplay as a universal HTTP cache.
Start with the user-visible failure, then inspect the layer that can actually create it. Do not prescribe switchMap merely because two requests overlap. A stale search result, duplicated mutation, request that survives navigation, and server job that outlives a browser tab require different evidence and may require different fixes.
Symptom | Likely root cause | Proof | Fix |
|---|---|---|---|
Old search result replaces the newest result | Concurrent inner requests or an unguarded imperative callback | Throttle the network, label requests A/B, and observe out-of-order state commits | Use switchMap for latest-only reads or reject stale generations before committing |
Request remains active after navigation | A manual subscription outlives its component or a shared source still owns it | Add finalize logs and inspect the Network request after component destruction | Use AsyncPipe or takeUntilDestroyed; review shareReplay refCount ownership |
Cancel button changes the UI but bytes keep transferring | The code changed state without unsubscribing | Check subscription.closed and DevTools transfer activity | Unsubscribe the active request and model an explicit canceled state |
Duplicate saves or payments | The chosen operator allows unsafe parallel writes or retries are not idempotent | Trace request IDs and server-side idempotency keys | Queue, exhaust, or deduplicate writes according to the product contract |
Browser says canceled but server CPU continues | The server handler does not observe client disconnect cancellation | Correlate the client request ID with server tracing | Propagate a cancellation signal server-side or move long work to a job with explicit status |
Last subscriber leaves but shared request continues | shareReplay keeps the upstream source subscribed | Compare finalize logs with refCount true and false | Choose refCount and invalidation deliberately; do not assume downstream unsubscribe owns the source |
1. Browser DevTools Network: enable network throttling, start a request, trigger the cancellation boundary, and inspect whether the request becomes canceled before its response finishes. Record the request ID and timing; status text differs across browsers and HTTP backends. 2. RxJS teardown: place finalize() beside the request or return a teardown function from a controlled Observable. This proves subscription cleanup, although finalize also runs on completion and error, so record the event that preceded it. 3. Angular HTTP testing: capture the pending request with HttpTestingController.expectOne(), tear down its subscriber, and assert TestRequest.cancelled. Then separately test the UI state so a transport assertion does not hide a stale-commit bug.
This focused Jasmine suite isolates the six ownership patterns. It uses Angular's testing backend, so it is fast and deterministic: no real server, timer, or random delay is involved. Keep provideHttpClient() before provideHttpClientTesting(), match each request before acting on it, and retain verify() so unexpected requests still fail the test.
import { AsyncPipe } from '@angular/common';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { mergeMap, shareReplay, switchMap } from 'rxjs/operators';
@Component({ standalone: true, template: '' })
class LifecycleHost {
private readonly http = inject(HttpClient);
readonly request = this.http.get('/api/lifecycle').pipe(
takeUntilDestroyed()
).subscribe();
}
@Component({
standalone: true,
imports: [AsyncPipe],
template: '{{ data$ | async }}',
})
class AsyncPipeHost {
private readonly http = inject(HttpClient);
readonly data$ = this.http.get('/api/async-pipe');
}
describe('HttpClient cancellation contracts', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('cancels an active request on manual unsubscribe', () => {
const sub = http.get('/api/manual').subscribe();
const req = httpMock.expectOne('/api/manual');
sub.unsubscribe();
expect(req.cancelled).toBeTrue();
});
it('lets switchMap cancel the previous request', () => {
const queries = new Subject<string>();
const sub = queries.pipe(
switchMap(q => http.get('/api/search', { params: { q } }))
).subscribe();
queries.next('angular');
const first = httpMock.expectOne(r => r.params.get('q') === 'angular');
queries.next('rxjs');
const second = httpMock.expectOne(r => r.params.get('q') === 'rxjs');
expect(first.cancelled).toBeTrue();
expect(second.cancelled).toBeFalse();
second.flush({ items: [] });
sub.unsubscribe();
});
it('keeps concurrent mergeMap requests active', () => {
const queries = new Subject<string>();
const sub = queries.pipe(
mergeMap(q => http.get('/api/search', { params: { q } }))
).subscribe();
queries.next('angular');
queries.next('rxjs');
const first = httpMock.expectOne(r => r.params.get('q') === 'angular');
const second = httpMock.expectOne(r => r.params.get('q') === 'rxjs');
expect(first.cancelled).toBeFalse();
expect(second.cancelled).toBeFalse();
second.flush({ items: ['new'] });
first.flush({ items: ['old'] });
sub.unsubscribe();
});
it('cancels through takeUntilDestroyed', () => {
const fixture = TestBed.createComponent(LifecycleHost);
const req = httpMock.expectOne('/api/lifecycle');
fixture.destroy();
expect(req.cancelled).toBeTrue();
});
it('lets AsyncPipe cancel when its view is destroyed', () => {
const fixture = TestBed.createComponent(AsyncPipeHost);
fixture.detectChanges();
const req = httpMock.expectOne('/api/async-pipe');
fixture.destroy();
expect(req.cancelled).toBeTrue();
});
it('makes shareReplay cancellation depend on refCount', () => {
const refCounted$ = http.get('/api/ref-counted').pipe(
shareReplay({ bufferSize: 1, refCount: true })
);
const refCountedSub = refCounted$.subscribe();
const refCountedReq = httpMock.expectOne('/api/ref-counted');
refCountedSub.unsubscribe();
expect(refCountedReq.cancelled).toBeTrue();
const pinned$ = http.get('/api/pinned').pipe(
shareReplay({ bufferSize: 1, refCount: false })
);
const pinnedSub = pinned$.subscribe();
const pinnedReq = httpMock.expectOne('/api/pinned');
pinnedSub.unsubscribe();
expect(pinnedReq.cancelled).toBeFalse();
pinnedReq.flush({ ok: true });
});
});
Cancellation is a product decision before it is an RxJS decision. State what repeated user intent means, then choose an operator whose subscription behavior enforces it. Also test loading, error, retry, and disabled states; a correct flattening operator can still produce a confusing interface if those states do not follow the same ownership rule.
UX contract | Typical operator | What repeated intent means | Cancellation behavior |
|---|---|---|---|
Latest wins | switchMap | Replace the previous read, such as search or filters | Unsubscribes the prior active inner request |
Queue all | concatMap | Preserve order and let every operation finish | Does not cancel; starts the next after completion |
Run in parallel | mergeMap | Every operation is independent | Does not cancel; optionally bound concurrency |
First wins while busy | exhaustMap | Ignore repeated submit intent until the active operation finishes | Keeps the first request; never starts ignored repeats |
Timeout: Angular's request timeout option can terminate the backend request when its deadline is exceeded, so “only a user-written unsubscribe can abort HTTP” is too absolute. Interceptors may add delay before the backend request; the documented timeout applies to the backend request, not all interceptor time. Completed requests: most one-shot HttpClient Observables complete after their response. Unsubscribing afterward is cleanup with no transport left to abort. Interceptors: an interceptor can retry, cache, share, or replace an Observable, changing which subscription owns the backend request. Verify the final chain rather than assuming operator placement guarantees transport behavior.
A service worker, browser cache, interceptor cache, or already-completed shareReplay value may satisfy a subscriber without a new network request. In that case there is no in-flight transport to cancel, even though an Observable subscription still opens and closes. Conversely, aborting the browser's Fetch or XHR does not automatically roll back a database write or stop CPU work already accepted by the server. Long operations need server-side disconnect handling, cooperative cancellation, idempotency, or an asynchronous job API. Treat the browser abort as one signal in an end-to-end ownership design, not as a distributed transaction.
Continue with switchMap vs mergeMap vs concatMap vs exhaustMap, Angular subscription cleanup patterns, shareReplay failure modes, and the free takeLatest coding exercise. Together they cover intent replacement, component lifetime, shared-source ownership, and stale-response defense without assuming every problem is solved by one operator.
A strong answer starts with the direct rule: tearing down an active HttpClient subscription aborts the client request. It then names ownership: switchMap replaces the previous inner subscription, takeUntilDestroyed and AsyncPipe tie teardown to a view lifetime, mergeMap preserves concurrency, and shareReplay can keep upstream ownership after a consumer leaves. Finish by separating subscription, transport, UI commit, and server work, and explain how DevTools, finalize, and TestRequest.cancelled prove different parts of the contract.