HttpClient and interceptors in Angular
HttpClient is Angular's typed HTTP service that wraps every request in an RxJS Observable. Interceptors are middleware functions that sit in front of every request and response, letting you transform them globally without touching individual service calls.
Theory
TL;DR
HttpClientanalogy: a post office that handles serialization and delivery - you hand it a request, it handles the rest- Interceptor analogy: a security checkpoint every package passes through - inspect, modify, or reject
- Main difference:
HttpClientmakes calls; interceptors modify those calls globally - Use interceptors for cross-cutting concerns: auth tokens, logging, global error handling
- Interceptors run in registration order; each can short-circuit the chain by not calling
next()
Quick example
// HttpClient: typed GET request
const users$ = this.http.get<User[]>('/api/users');
// Functional interceptor: adds auth token to EVERY request automatically
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (!token) return next(req);
const cloned = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
return next(cloned); // pass modified request forward
};
// Result: every HTTP call gets the token with zero repetition in servicesHttpRequest is immutable by design. Always use clone() to create a modified copy.
Key difference
HttpClient is the request maker. You call .get(), .post(), .put() and get an Observable back. Interceptors are request modifiers - they process the Observable before bytes leave your machine and again when the response arrives. Stack multiple interceptors and they run in sequence. The response flows back through the same chain in reverse.
When to use
HttpClientdirectly: any specific HTTP call in a service or component- Auth interceptor: attach JWT tokens once, not in every service method
- Error interceptor: catch 401/403 globally and redirect to login
- Logging interceptor: track request timing and failures across the entire app
- Caching interceptor: return stored GET responses without hitting the network
- Retry interceptor: automatically retry failed requests with a delay
Comparison table
| Aspect | HttpClient | Interceptor |
|---|---|---|
| Purpose | Makes HTTP calls | Transforms requests/responses globally |
| Scope | One request at a time | All requests matching your conditions |
| Runs when | You call .get(), .post(), etc. | Before every request, after every response |
| Can modify request | Yes, per call via options | Yes, for all calls at once |
| Can short-circuit | No | Yes - skip next() to cancel or serve cached data |
| Typical use | API calls in services | Auth, logging, errors, caching, retry |
How the interceptor chain works
When you call this.http.get(), Angular creates an HttpRequest and passes it through each registered interceptor in order. Each interceptor can clone and modify the request, then calls next(cloned) to forward it. The last handler actually sends the request to the network. The response then travels back through the same chain in reverse. If an interceptor skips next(), the chain stops and no request is sent. That is how caching interceptors return stored data without touching the network.
Modern Angular (v15+) uses functional interceptors registered via withInterceptors(). Class-based interceptors with implements HttpInterceptor still work but are not the recommended pattern anymore.
// main.ts - registering interceptors
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor, // runs first
loggingInterceptor, // runs second
errorInterceptor, // runs third
])
),
],
});Common mistakes
Forgetting to return next() - the request never sends
// WRONG: clones the request but never forwards it
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const cloned = req.clone({ setHeaders: { 'X-Custom': 'value' } });
// Missing: return next(cloned);
};
// RIGHT
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const cloned = req.clone({ setHeaders: { 'X-Custom': 'value' } });
return next(cloned);
};Mutating the request instead of cloning it
// WRONG: HttpRequest is immutable, this has no effect
export const authInterceptor: HttpInterceptorFn = (req, next) => {
req.headers.set('Authorization', 'Bearer token'); // no-op
return next(req); // headers unchanged
};
// RIGHT: clone() creates a new request with the changes applied
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const cloned = req.clone({
setHeaders: { Authorization: 'Bearer token' }
});
return next(cloned);
};Swallowing errors instead of re-throwing them
// WRONG: component never knows the request failed
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError(() => of(null)) // error disappears
);
};
// RIGHT: handle what you need, then re-throw
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) inject(Router).navigate(['/login']);
return throwError(() => error);
})
);
};Registering class-based interceptors in lazy-loaded modules
// WRONG: if SharedModule is imported in multiple places,
// the interceptor runs multiple times per request
@NgModule({
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
]
})
export class SharedModule {}
// RIGHT with modern Angular: use functional interceptors
// registered once in provideHttpClient(withInterceptors([...]))Creating infinite retry loops
// WRONG: retrying a 401 without refreshing the token loops forever
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
retry(3) // retries blindly including 401s that will never succeed
);
};
// RIGHT: limit retries and be specific about which errors warrant them
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
retry({ count: 3, delay: 1000 })
);
};Real-world usage
- NgRx apps: interceptors dispatch loading/success/error actions so the store reflects HTTP state automatically
- Firebase: interceptors attach ID tokens and refresh them when they expire
- Multi-environment APIs: interceptors prepend base URLs or version segments like
/v2/without touching service code - Sentry or Datadog: interceptors capture HTTP errors and send them to monitoring with full request context
- Response normalization: interceptors convert
snake_casefrom the backend tocamelCasefor the frontend
In practice, the most common setup is two separate interceptors: one for auth, one for errors. Keeping them split makes each one easier to test and debug independently.
Follow-up questions
Q: Why does HttpClient return Observables instead of Promises?
A: Observables are cancellable - unsubscribing stops the in-flight request. A Promise resolves once and cannot be cancelled. With Observables you also get operators like retry(), debounceTime(), and shareReplay() that make HTTP handling far more flexible.
Q: What happens when one interceptor in the chain throws an error?
A: The error propagates through each subsequent interceptor's catchError handler. If none catches it, it reaches the component's subscription. Order matters: register error-handling interceptors last if you want them to catch errors from all the others.
Q: How do you skip an interceptor for specific requests?
A: Add a custom header when making the request, then check for it inside the interceptor. Example: req.clone({ setHeaders: { 'X-Skip-Auth': 'true' } }). In the interceptor: if (req.headers.has('X-Skip-Auth')) return next(req);
Q: Can interceptors modify response bodies?
A: Yes. Use map() after next() and filter for HttpResponse events: map(event => event instanceof HttpResponse ? event.clone({ body: transform(event.body) }) : event). Streaming responses like file downloads need separate handling since the full body is not available as a single event.
Q: (Senior) How would you implement request deduplication - preventing duplicate calls to the same endpoint within 100ms?
A: Keep a Map<string, Observable> of pending requests keyed by URL. When the same URL arrives within the time window, return the cached Observable using shareReplay(1) instead of making a new call. Clear the entry once the request completes or errors out.
Examples
Basic: HttpClient with typed requests and error handling
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users').pipe(
retry(2),
catchError((error: HttpErrorResponse) => {
if (error.status === 404) return of([]);
return throwError(() => error);
})
);
}
createUser(user: CreateUserDto): Observable<User> {
return this.http.post<User>('/api/users', user);
}
searchUsers(query: string): Observable<User[]> {
return this.http.get<User[]>('/api/users', {
params: { q: query, limit: '10' }
});
}
}
// HttpClient handles JSON serialization automatically.
// The <User[]> generic gives you type safety on the response body.The retry(2) retries the request twice before giving up. For 404 responses the service returns an empty array instead of throwing, which is a common pattern when a missing resource is not an error state for the UI.
Intermediate: Auth interceptor with token refresh
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
// Skip token for login endpoint
if (req.url.includes('/auth/login')) return next(req);
const token = auth.getToken();
if (!token) {
router.navigate(['/login']);
return throwError(() => new Error('No token'));
}
const cloned = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
return next(cloned).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Token expired - try to refresh once
return auth.refreshToken().pipe(
switchMap((newToken) => {
const retried = req.clone({
setHeaders: { Authorization: `Bearer ${newToken}` }
});
return next(retried);
}),
catchError(() => {
router.navigate(['/login']);
return throwError(() => error);
})
);
}
return throwError(() => error);
})
);
};
// The component making the request never knows a refresh happened.
// Token rotation is transparent to the rest of the app.Advanced: Caching interceptor with TTL
@Injectable({ providedIn: 'root' })
export class HttpCacheService {
private cache = new Map<string, { body: any; timestamp: number }>();
private readonly TTL = 5 * 60 * 1000; // 5 minutes
get(url: string): any | null {
const entry = this.cache.get(url);
if (!entry || Date.now() - entry.timestamp > this.TTL) return null;
return entry.body;
}
set(url: string, body: any): void {
this.cache.set(url, { body, timestamp: Date.now() });
}
}
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
const cache = inject(HttpCacheService);
if (req.method !== 'GET') return next(req);
const cached = cache.get(req.url);
if (cached) {
return of(new HttpResponse({ body: cached, status: 200 }));
}
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
cache.set(req.url, event.body);
}
})
);
};
// For cached URLs the chain stops here - next() is never called.
// No network request is made for fresh cache entries.This interceptor short-circuits the chain for GET requests with valid cache entries. The component receives an HttpResponse object identical in shape to a real response, so no special handling is needed on the calling side.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.