Angular

Interceptors

15 Questions

An HTTP interceptor intercepts and optionally transforms HTTP requests and responses. It sits between the HttpClient and the server, allowing you to modify requests before they are sent and responses before they reach the caller. Common use cases include adding authentication headers, logging, caching, and error handling. Interceptors implement the HttpInterceptor interface.
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const authReq = req.clone({
      setHeaders: { Authorization: 'Bearer ' + this.token }
    });
    return next.handle(authReq);
  }
}
Requests are immutable, so you must use clone() to create a modified copy. The next.handle() passes the request to the next interceptor or the backend.

Why it matters: Tests understanding of cross-cutting concerns and middleware patterns in Angular. Shows knowledge of how to implement enterprise-level features globally.

Real applications: Authentication, logging, error centralization, and caching all implemented via interceptors rather than repeating code in every service.

Common mistakes: Developers forget to clone the immutable request, causing errors. They don't understand interceptors sit between HttpClient and the server. They use interceptors for things better handled at the component level.

Register interceptors as multi-providers in the module or with withInterceptors for standalone applications. Multi-providers allow multiple interceptors to be registered under the same HTTP_INTERCEPTORS token. The order of registration determines the order of execution. Standalone apps use the newer provideHttpClient API.
// Module-based
providers: [{
  provide: HTTP_INTERCEPTORS,
  useClass: AuthInterceptor,
  multi: true
}]

// Standalone (Angular 15+)
provideHttpClient(withInterceptors([authInterceptor]))
The multi: true flag is required to allow multiple interceptors. Without it, each registration would overwrite the previous one.

Why it matters: Tests understanding of Angular dependency injection and multi-provider tokens. Shows you can register multiple instances of a provider.

Real applications: Every production app with HTTP needs at least one interceptor. Most apps have auth, logging, and error-handling interceptors all registered together.

Common mistakes: Developers forget the multi: true flag, accidentally overwriting previous interceptors. They don't understand the difference between module-based and standalone registration patterns.

Angular 15+ supports functional interceptors which are plain functions instead of class-based interceptors. They receive the request and a next function as parameters. Functional interceptors are more concise and use inject() for dependencies. They are registered with provideHttpClient(withInterceptors(...)).
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  console.log('Request:', req.url);
  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        console.log('Response:', event.status);
      }
    })
  );
};
Functional interceptors are the recommended approach for new Angular projects. They avoid the boilerplate of creating injectable classes.

Why it matters: Tests knowledge of modern Angular (15+) evolution from class-based to functional patterns. Shows you're familiar with contemporary Angular best practices.

Real applications: All new standalone Angular applications use functional interceptors. They're simpler and more concise than class-based alternatives.

Common mistakes: Developers still use class-based interceptors in new projects. They don't know about the inject() API for functional dependencies. They confuse functional interceptors with middleware.

Error handling interceptors catch HTTP errors globally instead of handling them in every service call. Use the catchError operator on the response Observable to intercept error responses. Common patterns include redirecting to login on 401 Unauthorized or showing notification messages. The interceptor can also transform errors into user-friendly messages.
intercept(req: HttpRequest<any>, next: HttpHandler) {
  return next.handle(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        this.auth.logout();
        this.router.navigate(['/login']);
      }
      return throwError(() => error);
    })
  );
}
Always re-throw the error with throwError so that individual service calls can still handle specific errors. This provides both global and local error handling.

Why it matters: Tests understanding of error handling architecture and RxJS error operators. Shows you can centralize error handling while allowing specific overrides.

Real applications: 401 redirects to login, 403 shows forbidden message, 5xx shows retry dialogs all centralized in one interceptor.

Common mistakes: Developers swallow errors entirely, preventing services from handling them. They don't re-throw, breaking downstream error handling. They try to show user messages in the interceptor instead of passing them through.

Retry interceptors automatically re-send failed requests a specified number of times before giving up. Use the retry operator from RxJS to configure the number of attempts and delay between retries. This is useful for handling temporary network issues or server hiccups. You can also use retryWhen for more complex retry strategies.
intercept(req: HttpRequest<any>, next: HttpHandler) {
  return next.handle(req).pipe(
    retry({ count: 3, delay: 1000 }),
    catchError(err => throwError(() => err))
  );
}
Only retry idempotent requests like GET. Retrying POST requests can cause duplicate data. Add conditions to skip retry for non-GET methods.

Why it matters: Tests understanding of idempotency and resilient systems. Shows you know the risks of blindly retrying all requests.

Real applications: Network timeouts on data fetching auto-retry. File uploads with transient failures retry automatically. Race conditions avoided by only retrying safe operations.

Common mistakes: Developers retry POST/DELETE requests, creating duplicate orders or data. They don't add delays between retries. They retry forever without a limit.

Yes, use the map operator to transform responses in an interceptor. Since responses are immutable, you must use clone() to create a modified copy with a new body. This is useful for unwrapping API responses that wrap data in a standard envelope. You can also transform dates, add default values, or normalize data.
return next.handle(req).pipe(
  map(event => {
    if (event instanceof HttpResponse) {
      return event.clone({ body: event.body.data });
    }
    return event;
  })
);
Check for HttpResponse instances since the Observable also emits other event types. Only modify the final response, not intermediate events like progress updates.

Why it matters: Tests understanding of Observable event types and response transformation patterns. Shows you can normalize API responses transparently.

Real applications: APIs often wrap responses in envelopes like { data: {...}, meta: {...} }. Interceptors unwrap these transparently, simplifying component code.

Common mistakes: Developers modify all events, including progress updates. They don't clone the response, mutating the original. They don't check for HttpResponse type, causing errors on other events.

Interceptors execute in the order they are provided for requests, and in reverse order for responses. The first interceptor registered is the first to handle the request and the last to handle the response. This creates a chain where each interceptor wraps the next one. Understanding this order is important when interceptors depend on each other.
// Registration order
provideHttpClient(withInterceptors([
  loggingInterceptor,   // 1st request, 3rd response
  authInterceptor,      // 2nd request, 2nd response
  cacheInterceptor      // 3rd request, 1st response
]))
Place logging interceptors first to capture all requests. Place cache interceptors last so cached responses skip other interceptors.

Why it matters: Tests understanding of interceptor composition and execution order. Shows you know how to architect interceptor chains for efficiency.

Real applications: Logging first to capture all requests, auth second to add tokens, cache last to avoid modifying cached data.

Common mistakes: Developers don't consider ordering, causing unexpected behavior. They place the cache interceptor first, logging transformations instead of raw requests. They don't understand reverse order for responses.

Use custom headers or HttpContext to mark requests that should skip certain interceptors. The interceptor checks for the marker and either processes the request normally or passes it through unchanged. Remember to remove the marker header before sending the request to avoid sending it to the server. HttpContext is the cleaner approach in modern Angular.
// Set a marker header
const req = new HttpRequest('GET', url, { headers: new HttpHeaders({ 'Skip-Auth': 'true' }) });

// In interceptor
if (req.headers.has('Skip-Auth')) {
  return next.handle(req.clone({ headers: req.headers.delete('Skip-Auth') }));
}
The HttpContext API is preferred over custom headers as it does not leak internal markers to the server. Context tokens are type-safe and invisible to the backend.

Why it matters: Tests knowledge of advanced HttpClient APIs and clean architecture patterns. Shows you can skip specific interceptor logic selectively.

Real applications: Bypassing auth interceptor for public APIs, skipping cache for fresh data requests, excluding specific endpoints from logging.

Common mistakes: Developers use custom headers that leak to the server. They don't know about HttpContext. They hardcode skip logic instead of making it configurable.

A loading interceptor shows a spinner when HTTP requests are in progress and hides it when they complete. Use a LoadingService to manage the spinner state. The finalize operator ensures the spinner is hidden regardless of whether the request succeeds or fails. This provides a centralized loading indicator for all HTTP calls.
intercept(req: HttpRequest<any>, next: HttpHandler) {
  this.loadingService.show();
  return next.handle(req).pipe(
    finalize(() => this.loadingService.hide())
  );
}
Use a counter in the LoadingService to handle multiple concurrent requests. Only hide the spinner when the counter reaches zero.

Why it matters: Tests understanding of concurrent HTTP request handling and user feedback mechanisms. Shows you can provide visual feedback for all API activity.

Real applications: Any app with async data needs a loading indicator. This interceptor makes every HTTP request automatically show loading UI without component code.

Common mistakes: Developers hide the spinner on first request completion, ignoring other concurrent requests. They don't use a counter. They hardcode the spinner in components instead of centralizing.

Yes, interceptors have access to both requests and responses. The request is the req parameter, and the response comes from the Observable returned by next.handle(req). You can modify the request before sending and transform the response when it arrives. This makes interceptors powerful for cross-cutting concerns like authentication.
intercept(req: HttpRequest<any>, next: HttpHandler) {
  // Modify request
  const modifiedReq = req.clone({ setHeaders: { 'X-Custom': 'value' } });

  // Handle response
  return next.handle(modifiedReq).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        console.log('Response received:', event.status);
      }
    })
  );
}
Use tap for side effects like logging and map for transforming the response body. Keep request and response logic in the same interceptor when they are related.

Why it matters: Tests understanding of RxJS operators and symmetrical request/response handling. Shows you know how to implement bidirectional interceptor logic.

Real applications: Add auth token on request, log response status. Add request headers, transform response format.

Common mistakes: Developers create separate interceptors for requests and responses when they should be together. They use map for logging instead of tap. They don't understand that interceptors wrap both directions.

Class-based interceptors implement the HttpInterceptor interface and are registered using the HTTP_INTERCEPTORS multi-provider token. Functional interceptors (Angular 15+) are plain functions that receive the request and a next function. Functional interceptors are simpler, more concise, and work with provideHttpClient(withInterceptors(...)). They use inject() for dependencies instead of constructor injection.
// Functional interceptor (modern)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(AuthService).getToken();
  const authReq = req.clone({ setHeaders: { Authorization: 'Bearer ' + token } });
  return next(authReq);
};

// Register
provideHttpClient(withInterceptors([authInterceptor]))
New projects should prefer functional interceptors as they are the modern approach. They avoid the boilerplate of creating injectable classes.

Why it matters: Tests knowledge of Angular evolution and when to use modern patterns. Functional interceptors are the recommended standard going forward.

Real applications: New standalone components use functional interceptors with provideHttpClient(). Legacy module-based apps still use class-based interceptors.

Common mistakes: Developers continue using class-based patterns in new projects. They don't realize functional interceptors are simpler and more testable. They mix both patterns confusingly.

A token refresh interceptor catches 401 Unauthorized responses, refreshes the access token using a refresh token, and retries the original request with the new token. The key challenge is handling multiple concurrent requests during the refresh process. Use a flag and a Subject to queue requests while the refresh is in progress. Once the new token arrives, replay all queued requests.
export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
  const auth = inject(AuthService);
  return next(req).pipe(
    catchError(error => {
      if (error.status === 401) {
        return auth.refreshToken().pipe(
          switchMap(newToken => {
            const retryReq = req.clone({
              setHeaders: { Authorization: 'Bearer ' + newToken }
            });
            return next(retryReq);
          })
        );
      }
      return throwError(() => error);
    })
  );
};
Use switchMap to chain the retry after the token refresh. Handle cases where the refresh token itself is expired by redirecting to login.

Why it matters: Tests deep understanding of authentication patterns and RxJS operators in real-world scenarios. Shows you can implement sophisticated security flows.

Real applications: SPA apps with JWT tokens need token refresh logic. When tokens expire (401), fetch new token and retry original request seamlessly.

Common mistakes: Developers don't handle multiple concurrent 401 responses, causing multiple refresh attempts. They don't queue requests during refresh. They don't handle refresh token expiration.

A cache interceptor stores GET request responses and returns cached data for subsequent identical requests. Check if the request URL already has a cached response in a Map. If yes, return it as an Observable using of(). If not, forward the request and store the response. Only cache GET requests and implement cache expiration for production use.
const cache = new Map<string, HttpResponse<any>>();

export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
  if (req.method !== 'GET') return next(req);

  const cached = cache.get(req.urlWithParams);
  if (cached) return of(cached.clone());

  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        cache.set(req.urlWithParams, event.clone());
      }
    })
  );
};
This reduces server load and improves responsiveness for data that does not change frequently. Add cache invalidation logic for mutable data.

Why it matters: Tests understanding of performance optimization and caching strategies. Shows you know how to reduce unnecessary API calls.

Real applications: Caching product lists, user profiles, and config data. Skipping cache for refresh requests or time-limited cache expiration.

Common mistakes: Developers cache all requests indiscriminately, showing stale data. They don't implement cache invalidation leading to outdated information. They cache mutable operations like POST/PUT.

A logging interceptor records the URL, method, timing, and response status of every HTTP request for debugging or analytics. Capture the start time when the request goes out, then use the tap operator to log details when the response arrives. This gives you visibility into API performance and helps debug slow requests.
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const startTime = Date.now();
  console.log('Request:', req.method, req.url);

  return next(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        const duration = Date.now() - startTime;
        console.log('Response:', req.url, event.status, duration + 'ms');
      }
    })
  );
};
In production, send logs to a monitoring service instead of the console. Use the finalize operator to also capture failed requests.

Why it matters: Tests understanding of observability and debugging in production. Shows you know how to track API performance and errors.

Real applications: Every production app needs request logging for performance monitoring, error tracking, and debugging user issues.

Common mistakes: Developers log to console in production, overwhelming logs. They don't log failures. They don't measure timing or include request/response sizes.

Yes, interceptors can transform the response body using the map operator on the Observable returned by next.handle(). Use clone() on the HttpResponse to create a modified copy with a new body. This is useful for unwrapping API responses that wrap data in a standard envelope. The original response is immutable, so you always create a clone.
export const unwrapInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    map(event => {
      if (event instanceof HttpResponse && event.body?.data) {
        // Unwrap: { data: [...], meta: {...} } → [...]
        return event.clone({ body: event.body.data });
      }
      return event;
    })
  );
};
Common transformations include unwrapping envelopes, converting date strings to Date objects, and adding default values to responses.

Why it matters: Tests understanding of response transformation and API normalization. Shows you can adapt component code to any API response format.

Real applications: APIs often return wrapped responses. Interceptors can normalize to match your component types, simplifying business logic.

Common mistakes: Developers unwrap responses in components repeatedly instead of centralizing in interceptors. They don't verify the response has the expected structure. They transform all events instead of just HttpResponse.