Angular 21+ functional HTTP interceptors for auth, error handling, loading states, retry logic, caching, and security best practices
Installation
Details
Usage
After installing, this skill will be available to your AI coding assistant.
Verify installation:
npx agent-skills-cli listSkill Instructions
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) => {
// Receive outgoing HttpRequest
// 'next' represents the next processing step in chain
// Process or modify request
const modifiedReq = req.clone({
/* ... */
});
// Pass to next interceptor or make request
return next(modifiedReq);
};
Configuration
Interceptors are chained together in the order listed via dependency injection:
// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
authInterceptor, // First
retryInterceptor, // Second
cacheInterceptor, // Third
errorInterceptor, // Last (should always be last)
]),
),
],
};
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.
// interceptors/auth.interceptor.ts
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();
// Skip auth for public endpoints
if (req.url.includes('/auth/login') || req.url.includes('/auth/register')) {
return next(req);
}
// Add token if available
if (!token) {
return next(req);
}
const authReq = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(authReq);
};
2. Error Interceptor
Handles HTTP errors globally.
// interceptors/error.interceptor.ts
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) {
// Client-side error
errorMessage = error.error.message;
} else {
// Server-side error
switch (error.status) {
case 401:
errorMessage = 'Unauthorized. Please login again.';
router.navigate(['/auth/login']);
break;
case 403:
errorMessage = 'Access forbidden.';
break;
case 404:
errorMessage = 'Resource not found.';
break;
case 500:
errorMessage = 'Server error. Please try again later.';
break;
default:
errorMessage = error.error?.message || error.message;
}
}
// Show notification
notification.error(errorMessage);
// Re-throw for component-level handling if needed
return throwError(() => new Error(errorMessage));
}),
);
};
3. Loading Interceptor
Manages global loading state.
// interceptors/loading.interceptor.ts
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);
// Skip loading for certain requests
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.
// interceptors/retry.interceptor.ts
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { retry, timer } from 'rxjs';
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
// Only retry GET requests and specific errors
const shouldRetry = (error: unknown) => {
if (!(error instanceof HttpErrorResponse)) return false;
if (req.method !== 'GET') return false;
// Retry on network errors or 5xx server errors
return error.status === 0 || error.status >= 500;
};
return next(req).pipe(
retry({
count: 3,
delay: (error, retryCount) => {
if (!shouldRetry(error)) {
throw error;
}
// Exponential backoff: 1s, 2s, 4s
const delayMs = Math.pow(2, retryCount - 1) * 1000;
return timer(delayMs);
},
}),
);
};
5. Cache Interceptor
Caches GET requests to avoid duplicate calls.
// interceptors/cache.interceptor.ts
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);
// Only cache GET requests
if (req.method !== 'GET') {
return next(req);
}
// Check if cached
const cachedResponse = cache.get(req.url);
if (cachedResponse) {
return of(cachedResponse);
}
// Make request and cache
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
cache.set(req.url, event);
}
}),
share(), // Share to prevent duplicate in-flight requests
);
};
Supporting Services
TokenService
// services/token.service.ts
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(): boolean {
return this.getToken() !== null;
}
}
NotificationService
// services/notification.service.ts
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,
};
this.notificationsSignal.update((notifications) => [...notifications, notification]);
if (duration > 0) {
setTimeout(() => {
this.dismiss(notification.id);
}, duration);
}
}
success(message: string, duration?: number): void {
this.show('success', message, duration);
}
error(message: string, duration?: number): void {
this.show('error', message, duration);
}
info(message: string, duration?: number): void {
this.show('info', message, duration);
}
warning(message: string, duration?: number): void {
this.show('warning', message, duration);
}
dismiss(id: string): void {
this.notificationsSignal.update((notifications) => notifications.filter((n) => n.id !== id));
}
clear(): void {
this.notificationsSignal.set([]);
}
}
LoadingService
// services/loading.service.ts
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
// services/http-cache.service.ts
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; // 5 minutes
get(url: string): HttpResponse<unknown> | null {
const entry = this.cache.get(url);
if (!entry) return null;
// Check if expired
if (Date.now() - entry.timestamp > this.defaultTTL) {
this.cache.delete(url);
return null;
}
return entry.response;
}
set(url: string, response: HttpResponse<unknown>): void {
this.cache.set(url, {
response,
timestamp: Date.now(),
});
}
clear(url?: string): void {
if (url) {
this.cache.delete(url);
} else {
this.cache.clear();
}
}
clearPattern(pattern: RegExp): void {
for (const key of this.cache.keys()) {
if (pattern.test(key)) {
this.cache.delete(key);
}
}
}
}
Configuration
Register Interceptors in App Config
// app.config.ts
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, // Error should be last
]),
),
// ... other providers
],
};
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
// services/lru-cache.service.ts
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; // 5 minutes
get(url: string): HttpResponse<unknown> | null {
const entry = this.cache.get(url);
if (!entry) return null;
// Check TTL
if (Date.now() - entry.timestamp > this.defaultTTL) {
this.cache.delete(url);
return null;
}
// Move to end (most recently used)
this.cache.delete(url);
this.cache.set(url, entry);
return entry.response;
}
set(url: string, response: HttpResponse<unknown>): void {
// Remove oldest if at capacity
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(url, {
response,
timestamp: Date.now(),
});
}
}
In-Flight Request Deduplication
Prevents duplicate parallel requests using shareReplay:
// services/request-deduplication.service.ts
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> {
// Return existing request if in-flight
if (this.inFlightRequests.has(key)) {
return this.inFlightRequests.get(key) as Observable<T>;
}
// Create new request with share operator
const sharedRequest = request().pipe(
share({
// Remove from map when complete
resetOnComplete: () => {
this.inFlightRequests.delete(key);
},
}),
);
this.inFlightRequests.set(key, sharedRequest);
return sharedRequest;
}
}
Usage in Interceptor:
export const deduplicationInterceptor: HttpInterceptorFn = (req, next) => {
const dedup = inject(RequestDeduplicationService);
// Only deduplicate GET requests
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(() => {
// Invalidate all task-related caches
this.cache.clearPattern(/\/api\/tasks/);
}),
);
}
updateTask(id: string, updates: Partial<Task>): Observable<Task> {
return this.http.put<Task>(`/api/tasks/${id}`, updates).pipe(
tap(() => {
// Invalidate specific task and list caches
this.cache.clear(`/api/tasks/${id}`);
this.cache.clearPattern(/\/api\/tasks($|\?)/);
}),
);
}
}
Conditional Caching with HttpContext
Control caching per-request using HttpContext:
// Define context token
export const CACHE_ENABLED = new HttpContextToken<boolean>(() => true);
export const CACHE_TTL = new HttpContextToken<number>(() => 5 * 60 * 1000);
// Use in interceptor
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
const cache = inject(HttpCacheService);
// Check if caching is enabled for this request
if (!req.context.get(CACHE_ENABLED) || req.method !== 'GET') {
return next(req);
}
// Get custom TTL if provided
const ttl = req.context.get(CACHE_TTL);
// Check cache
const cached = cache.getWithTTL(req.urlWithParams, ttl);
if (cached) {
return of(cached);
}
// Make request and cache
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
cache.setWithTTL(req.urlWithParams, event, ttl);
}
}),
);
};
// Disable caching for specific request
this.http.get('/api/tasks', {
context: new HttpContext().set(CACHE_ENABLED, false),
});
// Custom TTL for request
this.http.get('/api/stats', {
context: new HttpContext().set(CACHE_TTL, 60000), // 1 minute
});
Security Best Practices
JWT Token Handling
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const tokenService = inject(TokenService);
const router = inject(Router);
// Get token
const token = tokenService.getToken();
if (!token) {
return next(req);
}
// Check token expiration
if (tokenService.isTokenExpired(token)) {
tokenService.removeToken();
router.navigate(['/auth/login']);
return throwError(() => new Error('Token expired'));
}
// Add token to request
const authReq = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(authReq);
};
CSRF Protection
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
// Skip for GET, HEAD, OPTIONS
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next(req);
}
// Get CSRF token from cookie or meta tag
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) {
// Clear token and redirect to login
tokenService.removeToken();
router.navigate(['/auth/login']);
notification.error('Session expired. Please login again.');
}
return throwError(() => error);
}),
);
};
Service Migration: Promises to Observables
Before (Promises)
// api.service.ts
@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'); // Manual token
return firstValueFrom(
this.http.post<T>(url, body, {
headers: { Authorization: `Bearer ${token}` },
}),
);
}
}
After (Observables)
// api.service.ts
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
get<T>(url: string): Observable<T> {
return this.http.get<T>(url);
// Auth token added by interceptor
// Errors handled by interceptor
// Loading state managed by interceptor
}
post<T>(url: string, body: unknown): Observable<T> {
return this.http.post<T>(url, body);
// All cross-cutting concerns handled by interceptors
}
}
Component Usage
export class MyComponent {
private api = inject(ApiService);
protected dataState = new AsyncState<Data[]>();
async loadData(): Promise<void> {
await this.dataState.execute(async () => {
// Convert Observable to Promise
return firstValueFrom(this.api.get<Data[]>('/api/data'));
});
}
// Or use Observable directly
protected data$ = this.api.get<Data[]>('/api/data');
}
Advanced Patterns
Request Deduplication
// services/request-deduplication.service.ts
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);
return sharedRequest;
}
}
Conditional Loading Indicator
// Skip loading for background requests
this.http.get('/api/data', {
headers: new HttpHeaders({ 'X-Skip-Loading': 'true' }),
});
Cache Invalidation
// Clear cache after mutation
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(() => {
// Invalidate tasks list cache
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', () => {
// Test implementation
});
});
Success Criteria
Before marking HTTP layer implementation complete:
- Auth interceptor configured (no manual token injection)
- Error interceptor handles all HTTP errors
- Loading interceptor manages global state
- Retry logic configured for transient failures
- Cache interceptor prevents duplicate requests
- All services return Observables (not Promises)
- TokenService abstracts token management
- NotificationService replaces console.log
- LoadingService provides global loading state
- Tests cover interceptor logic
References
Project-Specific
- GitHub Issue #257: HTTP Layer Improvements
.claude/agents/agent-frontend.md: Complete frontend patterns
Angular Functional Interceptors (2025)
- Intercepting requests and responses • Angular
- Functional Approach for HTTP Interceptors | JavaScript in Plain English
- Mastering Modern Angular: Functional Route Guards & Interceptors | Medium
- HTTP interceptors in Angular (2025 update) | Angular Training
Caching Strategies
- Client Side Caching With Interceptors | DEV Community
- Caching with HttpInterceptor in Angular | LogRocket
- Angular: Caching service using Http Interceptor | Medium
- Optimizing Angular Performance with HttpInterceptor Caching | OpenReplay
Security Best Practices
More by tidemann
View allCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
Angular 21+ state management with signals, NgRx SignalStore, resource APIs, AsyncState patterns, and localStorage abstraction for modern reactive applications
agent-database: PostgreSQL expert for .sql migration files, CREATE TABLE, ALTER TABLE, indexes, constraints, foreign keys, schema changes, docker/postgres/migrations/, init.sql, idempotent SQL, transactions, BEGIN/COMMIT, psql, database testing, schema_migrations
agent-cicd: GitHub Actions expert for CI/CD pipelines, workflows, build failures, test failures, lint errors, format checks, gh run, gh pr checks, ESLint, Prettier, TypeScript errors, quality gates, automated fixes, pipeline debugging, workflow monitoring
