| name | http-interceptors |
| description | Angular 21+ functional HTTP interceptors for auth, error handling, loading states, retry logic, caching, and security best practices |
| allowed-tools | Read, Write, Edit, Glob, Grep |
HTTP Interceptors Skill
Expert in implementing Angular 21+ functional HTTP interceptors for cross-cutting concerns.
When to Use This Skill
Use this skill when:
- Setting up authentication with automatic token injection
- Implementing global error handling
- Adding loading state management
- Configuring retry logic for failed requests
- Implementing request caching/deduplication
- Converting API services from Promises to Observables
- Implementing security best practices (JWT, CSRF protection)
Angular 21 Functional Interceptors (2025)
Why Functional Interceptors?
Introduced in Angular v15+, functional interceptors are now the recommended approach over class-based interceptors:
Advantages:
- Less Boilerplate: Pure functions are simpler than classes
- Better Tree-Shaking: Smaller bundle sizes
- Enhanced Developer Experience: More readable and maintainable
- Composition: Higher-order functions enable advanced patterns
- Predictable Behavior: Especially in complex setups
Note: Class-based guard interfaces were deprecated in v16. While they still work for backward compatibility, all new development should use functional interceptors.
Basic Structure
import { HttpInterceptorFn } from '@angular/common/http';
export const myInterceptor: HttpInterceptorFn = (req, next) => {
const modifiedReq = req.clone({
});
return next(modifiedReq);
};
Configuration
Interceptors are chained together in the order listed via dependency injection:
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor,
retryInterceptor,
cacheInterceptor,
errorInterceptor,
]),
),
],
};
Order Matters: Interceptors execute in the order provided. Error handling should typically be last.
Core Principle
NEVER manually handle these concerns in individual services:
- ❌ Manual token injection in every request
- ❌ Per-service error handling
- ❌ Repetitive loading state management
- ❌ Manual retry logic
ALWAYS use interceptors for cross-cutting HTTP concerns.
Required Interceptors
1. Auth Interceptor
Automatically adds authentication token to all requests.
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { TokenService } from '../services/token.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const tokenService = inject(TokenService);
const token = tokenService.getToken();
if (req.url.includes('/auth/login') || req.url.includes('/auth/register')) {
return next(req);
}
if (!token) {
return next(req);
}
const authReq = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(authReq);
};
2. Error Interceptor
Handles HTTP errors globally.
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { NotificationService } from '../services/notification.service';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const notification = inject(NotificationService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
let errorMessage = 'An error occurred';
if (error.error instanceof ErrorEvent) {
errorMessage = error.error.message;
} else {
(error.) {
:
errorMessage = ;
router.([]);
;
:
errorMessage = ;
;
:
errorMessage = ;
;
:
errorMessage = ;
;
:
errorMessage = error.?. || error.;
}
}
notification.(errorMessage);
( (errorMessage));
}),
);
};
3. Loading Interceptor
Manages global loading state.
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { finalize } from 'rxjs';
import { LoadingService } from '../services/loading.service';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loadingService = inject(LoadingService);
if (req.headers.has('X-Skip-Loading')) {
const newReq = req.clone({
headers: req.headers.delete('X-Skip-Loading'),
});
return next(newReq);
}
loadingService.show();
return next(req).pipe(
finalize(() => {
loadingService.hide();
}),
);
};
4. Retry Interceptor
Automatically retries failed requests with exponential backoff.
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { retry, timer } from 'rxjs';
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
const shouldRetry = (error: unknown) => {
if (!(error instanceof HttpErrorResponse)) return false;
if (req.method !== 'GET') return false;
return error.status === 0 || error.status >= 500;
};
return next(req).pipe(
retry({
count: 3,
delay: (error, retryCount) => {
if (!shouldRetry(error)) {
throw error;
}
delayMs = .(, retryCount - ) * ;
(delayMs);
},
}),
);
};
5. Cache Interceptor
Caches GET requests to avoid duplicate calls.
import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { of, tap, share } from 'rxjs';
import { HttpCacheService } from '../services/http-cache.service';
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
const cache = inject(HttpCacheService);
if (req.method !== 'GET') {
return next(req);
}
const cachedResponse = cache.get(req.url);
if (cachedResponse) {
return of(cachedResponse);
}
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
cache.set(req., event);
}
}),
(),
);
};
Supporting Services
TokenService
import { Injectable, inject } from '@angular/core';
import { StorageService, STORAGE_KEYS } from './storage.service';
import { z } from 'zod';
@Injectable({
providedIn: 'root',
})
export class TokenService {
private readonly storage = inject(StorageService);
getToken(): string | null {
return this.storage.get(STORAGE_KEYS.AUTH_TOKEN, z.string());
}
setToken(token: string): void {
this.storage.set(STORAGE_KEYS.AUTH_TOKEN, token);
}
removeToken(): void {
this.storage.remove(STORAGE_KEYS.AUTH_TOKEN);
}
hasToken(): {
.() !== ;
}
}
NotificationService
import { Injectable, signal } from '@angular/core';
export interface Notification {
id: string;
type: 'success' | 'error' | 'info' | 'warning';
message: string;
duration?: number;
}
@Injectable({
providedIn: 'root',
})
export class NotificationService {
private readonly notificationsSignal = signal<Notification[]>([]);
readonly notifications = this.notificationsSignal.asReadonly();
private idCounter = 0;
private show(type: Notification['type'], message: string, duration = 5000): void {
const notification: Notification = {
id: `notification-${this.idCounter++}`,
type,
message,
duration,
};
..( [...notifications, notification]);
(duration > ) {
( {
.(notification.);
}, duration);
}
}
(: , ?: ): {
.(, message, duration);
}
(: , ?: ): {
.(, message, duration);
}
(: , ?: ): {
.(, message, duration);
}
(: , ?: ): {
.(, message, duration);
}
(: ): {
..( notifications.( n. !== id));
}
(): {
..([]);
}
}
LoadingService
import { Injectable, signal, computed } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class LoadingService {
private readonly countSignal = signal(0);
readonly isLoading = computed(() => this.countSignal() > 0);
show(): void {
this.countSignal.update((count) => count + 1);
}
hide(): void {
this.countSignal.update((count) => Math.max(0, count - 1));
}
reset(): void {
this.countSignal.set(0);
}
}
HttpCacheService
import { Injectable } from '@angular/core';
import { HttpResponse } from '@angular/common/http';
interface CacheEntry {
response: HttpResponse<unknown>;
timestamp: number;
}
@Injectable({
providedIn: 'root',
})
export class HttpCacheService {
private cache = new Map<string, CacheEntry>();
private readonly defaultTTL = 5 * 60 * 1000;
get(url: string): HttpResponse<unknown> | null {
const entry = this.cache.get(url);
if (!entry) return null;
if (Date.now() - entry.timestamp > this.) {
..(url);
;
}
entry.;
}
(: , : <>): {
..(url, {
response,
: .(),
});
}
(?: ): {
(url) {
..(url);
} {
..();
}
}
(: ): {
( key ..()) {
(pattern.(key)) {
..(key);
}
}
}
}
Configuration
Register Interceptors in App Config
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './interceptors/auth.interceptor';
import { errorInterceptor } from './interceptors/error.interceptor';
import { loadingInterceptor } from './interceptors/loading.interceptor';
import { retryInterceptor } from './interceptors/retry.interceptor';
import { cacheInterceptor } from './interceptors/cache.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor,
retryInterceptor,
cacheInterceptor,
loadingInterceptor,
errorInterceptor,
]),
),
],
};
Order matters: Interceptors run in the order provided.
Advanced Caching Patterns (2025 Best Practices)
Common Caching Pitfalls to Avoid
1. Infinite Cache Growth
- Problem: In-memory cache grows indefinitely
- Solution: Implement size limits or LRU eviction
2. In-Flight Request Duplication
- Problem: Multiple parallel requests to same URL before cache populates
- Solution: Store in-flight observable in cache with
shareReplay
3. Stale Data
- Problem: Cached responses return outdated data
- Solution: Implement TTL (Time-To-Live) and cache invalidation
LRU (Least Recently Used) Cache
import { Injectable } from '@angular/core';
import { HttpResponse } from '@angular/common/http';
interface CacheEntry {
response: HttpResponse<unknown>;
timestamp: number;
}
@Injectable({
providedIn: 'root',
})
export class LRUCacheService {
private cache = new Map<string, CacheEntry>();
private readonly maxSize = 100;
private readonly defaultTTL = 5 * 60 * 1000;
get(url: string): HttpResponse<unknown> | null {
const entry = this.cache.get(url);
if (!entry) return null;
if (Date.now() - entry. > .) {
..(url);
;
}
..(url);
..(url, entry);
entry.;
}
(: , : <>): {
(.. >= .) {
firstKey = ..().().;
..(firstKey);
}
..(url, {
response,
: .(),
});
}
}
In-Flight Request Deduplication
Prevents duplicate parallel requests using shareReplay:
import { Injectable } from '@angular/core';
import { Observable, share } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class RequestDeduplicationService {
private inFlightRequests = new Map<string, Observable<unknown>>();
deduplicate<T>(key: string, request: () => Observable<T>): Observable<T> {
if (this.inFlightRequests.has(key)) {
return this.inFlightRequests.get(key) as Observable<T>;
}
const sharedRequest = request().pipe(
share({
resetOnComplete: () => {
this.inFlightRequests.delete(key);
},
}),
);
..(key, sharedRequest);
sharedRequest;
}
}
Usage in Interceptor:
export const deduplicationInterceptor: HttpInterceptorFn = (req, next) => {
const dedup = inject(RequestDeduplicationService);
if (req.method !== 'GET') {
return next(req);
}
return dedup.deduplicate(req.urlWithParams, () => next(req));
};
Cache Invalidation on Mutations
export class TaskService {
private http = inject(HttpClient);
private cache = inject(HttpCacheService);
createTask(task: CreateTaskRequest): Observable<Task> {
return this.http.post<Task>('/api/tasks', task).pipe(
tap(() => {
this.cache.clearPattern(/\/api\/tasks/);
}),
);
}
updateTask(id: string, updates: Partial<Task>): Observable<Task> {
return this.http.put<Task>(`/api/tasks/${id}`, updates).pipe(
tap(() => {
this.cache.clear();
..();
}),
);
}
}
Conditional Caching with HttpContext
Control caching per-request using HttpContext:
export const CACHE_ENABLED = new HttpContextToken<boolean>(() => true);
export const CACHE_TTL = new HttpContextToken<number>(() => 5 * 60 * 1000);
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
const cache = inject(HttpCacheService);
if (!req.context.get(CACHE_ENABLED) || req.method !== 'GET') {
return next(req);
}
const ttl = req.context.get(CACHE_TTL);
const cached = cache.getWithTTL(req.urlWithParams, ttl);
if (cached) {
return of(cached);
}
(req).(
( {
(event ) {
cache.(req., event, ttl);
}
}),
);
};
..(, {
: ().(, ),
});
..(, {
: ().(, ),
});
Security Best Practices
JWT Token Handling
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const tokenService = inject(TokenService);
const router = inject(Router);
const token = tokenService.getToken();
if (!token) {
return next(req);
}
if (tokenService.isTokenExpired(token)) {
tokenService.removeToken();
router.navigate(['/auth/login']);
return throwError(() => new Error('Token expired'));
}
const authReq = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(authReq);
};
CSRF Protection
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next(req);
}
const csrfToken = getCsrfToken();
if (csrfToken) {
const secureReq = req.clone({
setHeaders: { 'X-CSRF-TOKEN': csrfToken },
});
return next(secureReq);
}
return next(req);
};
401 Error Handling and Redirect
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const tokenService = inject(TokenService);
const notification = inject(NotificationService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
tokenService.removeToken();
router.navigate(['/auth/login']);
notification.error('Session expired. Please login again.');
}
return throwError(() => error);
}),
);
};
Service Migration: Promises to Observables
Before (Promises)
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
async get<T>(url: string): Promise<T> {
return firstValueFrom(this.http.get<T>(url));
}
async post<T>(url: string, body: unknown): Promise<T> {
const token = localStorage.getItem('token');
return firstValueFrom(
this.http.post<T>(url, body, {
headers: { Authorization: `Bearer ${token}` },
}),
);
}
}
After (Observables)
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
get<T>(url: string): Observable<T> {
return this.http.get<T>(url);
}
post<T>(url: string, body: unknown): Observable<T> {
return this.http.post<T>(url, body);
}
}
Component Usage
export class MyComponent {
private api = inject(ApiService);
protected dataState = new AsyncState<Data[]>();
async loadData(): Promise<void> {
await this.dataState.execute(async () => {
return firstValueFrom(this.api.get<Data[]>('/api/data'));
});
}
protected data$ = this.api.get<Data[]>('/api/data');
}
Advanced Patterns
Request Deduplication
import { Injectable } from '@angular/core';
import { Observable, share } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class RequestDeduplicationService {
private inFlightRequests = new Map<string, Observable<unknown>>();
deduplicate<T>(key: string, request: () => Observable<T>): Observable<T> {
if (this.inFlightRequests.has(key)) {
return this.inFlightRequests.get(key) as Observable<T>;
}
const sharedRequest = request().pipe(
share({
resetOnComplete: () => {
this.inFlightRequests.delete(key);
},
}),
);
this.inFlightRequests.set(key, sharedRequest);
sharedRequest;
}
}
Conditional Loading Indicator
this.http.get('/api/data', {
headers: new HttpHeaders({ 'X-Skip-Loading': 'true' }),
});
Cache Invalidation
export class TaskService {
private http = inject(HttpClient);
private cache = inject(HttpCacheService);
createTask(task: CreateTaskRequest): Observable<Task> {
return this.http.post<Task>('/api/tasks', task).pipe(
tap(() => {
this.cache.clearPattern(/\/api\/tasks/);
}),
);
}
}
Testing
Testing with Interceptors
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './auth.interceptor';
describe('AuthInterceptor', () => {
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
],
});
httpMock = TestBed.inject(HttpTestingController);
});
it('should add auth token', () => {
});
});
Success Criteria
Before marking HTTP layer implementation complete:
References
Project-Specific
- GitHub Issue #257: HTTP Layer Improvements
.github/agents/frontend-agent.md: Complete frontend patterns
Angular Functional Interceptors (2025)
Caching Strategies
Security Best Practices