Introduction: The Problem Before Interceptors
Imagine you're building a real-world application. Your API calls need authentication headers on every single request. Your error responses need to refresh expired tokens. Failed requests should retry. Loading states should show spinners. Response times should be logged for debugging. And oh, some requests need to cache their results to avoid hammering the server.
Now imagine doing all of that inside every component that makes an API call. You'd be copy-pasting the same validation, error-handling, and auth logic across fifty different fetch statements. Your codebase becomes a nightmare of repetition. Bugs multiply. Maintenance becomes a horror show.
This is why HTTP interceptors exist. They're middleware for your HTTP layer — a central place where you can inspect, modify, and handle every request and response flowing through your application. Once you set them up, every API call automatically benefits from your authentication logic, error handling, retry policies, and logging without a single line of duplicate code.
This guide will take you from "what even is an interceptor?" through production-grade implementations in both React and Angular. We'll build real examples, hit the edge cases, and show you how to think about interceptor architecture so you're not wrestling with them six months from now.
What We'll Cover
- Core Concepts — What interceptors actually are and why you need them
- The Mental Model — How to think about request and response flow
- React + Axios — Setting up interceptors in React with working examples
- React in Production — Token refresh, 401 handling, and common mistakes
- Angular Interceptors — The functional approach and architectural patterns
- Angular in Production — Multiple interceptors, ordering, and complex scenarios
- React vs Angular — Direct comparison and when to use each approach
- Limitations & Production Architecture — What interceptors can't do and how to handle it
Part 1: Core Concepts
What Is an HTTP Interceptor?
An HTTP interceptor is a function (or class, depending on the framework) that sits between your application and the network. Every HTTP request passes through it before hitting the server. Every response passes through it after leaving the server. You can intercept at either or both stages.
Think of it like a mail sorter at a post office:
- Request interceptor = the sorter checks outgoing mail, adds postage and routing info, maybe redirects some packages
- Response interceptor = the sorter receives incoming mail, verifies stamps, opens unopened packages, and sorts them by recipient
The key idea: you intercept, you inspect, you can modify, then you pass it along (or stop it entirely if something's wrong).
Why Do We Need Interceptors?
Without interceptors, you'd handle every cross-cutting concern (auth, errors, logging) inside every component. With interceptors, you handle it once, globally.
Real-world scenarios interceptors solve:
- Authentication — Add JWT tokens to every request without thinking about it
- Error handling — Catch 401s globally, refresh the token, retry the request automatically
- Loading states — Show spinners by incrementing a global counter, hide when count hits zero
- Logging — Log every request/response for debugging and analytics
- Request modification — Add timestamps, version headers, request IDs for tracing
- Caching — Return cached data for GET requests, skip the network entirely
- Retry logic — Automatically retry failed requests with exponential backoff
- Response transformation — Unwrap API responses, normalize data shape
Each of these, without interceptors, would need to be duplicated across 20+ components. With interceptors, it's centralized, testable, and maintainable.
Part 2: The Interceptor Mental Model
Here's how to think about request/response flow:
┌─────────────────────────────────────────────────────────────┐
│ Your Component: this.http.get('/api/users') │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Request Interceptor #1 │
│ (add auth header, request ID, etc) │
└──────────────────┬───────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Request Interceptor #2 │
│ (maybe cache check, logging) │
└──────────────────┬───────────────────┘
│
▼
┌─────────────────┐
│ Network Request │
│ (actual HTTP) │
└─────────┬───────┘
│
▼
┌──────────────────────────────────────┐
│ Response Interceptor #2 │
│ (runs in *reverse* order) │
└──────────────────┬───────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Response Interceptor #1 │
│ (final processing) │
└──────────────────┬───────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Your Component: .then(data => console.log(data)) │
└─────────────────────────────────────────────────────────────┘
Key insight: Multiple interceptors run in sequence on the request, then in reverse order on the response. This matters when one interceptor depends on another.
Part 3: Request Interceptors
A request interceptor runs before the HTTP request leaves your app. You can:
- Modify the request (add headers, change the URL, add body data)
- Reject the request entirely (throw an error or return a rejection)
- Skip the network and return cached data
- Track the request (for loading counters, analytics)
General structure:
// Pseudocode — exact syntax varies by framework
const requestInterceptor = (config) => {
// Modify the config object
config.headers.Authorization = `Bearer ${token}`;
config.headers['X-Request-ID'] = generateRequestId();
// Return the modified config to let it continue
return config;
// Or reject it (this stops the request):
// throw new Error('Request blocked');
};
Common Request Interceptor Patterns
Pattern 1: Adding auth headers
// Every request gets a JWT token
interceptor.request = (config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
};
Pattern 2: Adding metadata
// Add a unique request ID and timestamp for tracing
interceptor.request = (config) => {
config.headers['X-Request-ID'] = `req-${Date.now()}-${Math.random()}`;
config.headers['X-Timestamp'] = new Date().toISOString();
return config;
};
Pattern 3: Modifying the URL
// Add query params or change the base URL dynamically
interceptor.request = (config) => {
config.url = `${process.env.REACT_APP_API_URL}${config.url}`;
// Also works: config.params = { ...config.params, v: '2' };
return config;
};
Part 4: Response Interceptors
A response interceptor runs after the HTTP response returns. Here's what you can do:
- Transform the response (unwrap nested data, normalize shape)
- Check the status code and decide what to do
- Retry the request if it failed
- Refresh auth tokens if they've expired
- Log the response
- Throw errors for upstream
.catch()handlers
General structure:
const responseInterceptor = {
success: (response) => {
// Handle 2xx responses
// Maybe unwrap the data
return response.data; // or return response as-is
},
error: (error) => {
// Handle 4xx, 5xx responses
// Maybe retry, refresh token, or transform the error
throw error; // or return recovery
}
};
Common Response Interceptor Patterns
Pattern 1: Unwrapping API responses
// Many APIs wrap data like: { status: 'ok', data: [...], errors: null }
// Unwrap to just get the data
interceptor.response.success = (response) => {
return response.data.data; // Flatten the response
};
Pattern 2: Handling specific status codes
interceptor.response.error = (error) => {
if (error.response?.status === 401) {
// Token expired — refresh it
return refreshTokenAndRetry(error.config);
}
if (error.response?.status === 429) {
// Rate limited — retry after delay
return retryWithBackoff(error.config);
}
// For other errors, rethrow
throw error;
};
Pattern 3: Logging
interceptor.response.success = (response) => {
console.log(`✅ ${response.config.method.toUpperCase()} ${response.config.url}`);
return response.data;
};
interceptor.response.error = (error) => {
console.error(
`❌ ${error.config.method.toUpperCase()} ${error.config.url}`,
error.response?.status,
error.message
);
throw error;
};
Part 5: Common Interceptor Use Cases
Use Case 1: Global Authentication
// Every request automatically includes the user's token
// No component needs to think about auth
const authInterceptor = (config) => {
const token = getStoredToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
};
Use Case 2: Handling 401 (Token Expired)
// If the server says "your token expired," refresh it and retry
const handleUnauthorized = async (error) => {
if (error.response?.status === 401) {
try {
const newToken = await refreshAccessToken();
storeToken(newToken);
// Retry the original request with new token
error.config.headers.Authorization = `Bearer ${newToken}`;
return httpClient.request(error.config);
} catch {
// Refresh failed — log the user out
redirectToLogin();
throw error;
}
}
throw error;
};
Use Case 3: Loading State Management
let activeRequests = 0;
const loadingInterceptor = {
request: (config) => {
activeRequests++;
showLoadingSpinner(); // Assume this function exists
return config;
},
response: {
success: (response) => {
activeRequests--;
if (activeRequests === 0) hideLoadingSpinner();
return response;
},
error: (error) => {
activeRequests--;
if (activeRequests === 0) hideLoadingSpinner();
throw error;
}
}
};
Use Case 4: Retry with Exponential Backoff
const retryInterceptor = {
response: {
error: async (error) => {
// Only retry on specific status codes (5xx, timeout, etc)
const shouldRetry =
error.response?.status >= 500 ||
error.code === 'ECONNABORTED';
if (!shouldRetry || error.config.retryCount >= 3) {
throw error;
}
error.config.retryCount = (error.config.retryCount || 0) + 1;
const delay = Math.pow(2, error.config.retryCount) * 1000; // 2s, 4s, 8s
await new Promise(resolve => setTimeout(resolve, delay));
return httpClient.request(error.config);
}
}
};
Use Case 5: Caching GET Requests
const cache = new Map();
const cachingInterceptor = {
request: (config) => {
// Only cache GET requests
if (config.method === 'get' && cache.has(config.url)) {
// Return cached response — skip the network entirely
const cachedResponse = cache.get(config.url);
return Promise.resolve(cachedResponse);
}
return config;
},
response: {
success: (response) => {
if (response.config.method === 'get') {
cache.set(response.config.url, response);
}
return response;
}
}
};
Part 6: Interceptor vs Middleware
Middleware (like Express middleware) runs on the server.
Interceptors run on the client, in the browser (or Node.js if you're using an HTTP client there).
| Aspect | Middleware | Interceptor |
|---|---|---|
| Where | Server-side | Client-side |
| Runs on | Every incoming request to the server | Every outgoing/incoming request from the app |
| Controls | Request routing, authentication, logging | Request headers, response transformation, caching |
| Framework | Express, Koa, Django, etc. | Axios, Fetch API, Angular HTTP client |
| Use for | Rate limiting, parsing body, CORS | Auth tokens, error handling, retries |
When you need both:
Client App
│
├─ Interceptor (add auth token)
│
▼ (Network)
Server
├─ Middleware (verify token, log request)
│
▼ (Route handler)
Database
Good architecture uses both: interceptors handle client-side concerns (auth, caching, retries), middleware handles server-side concerns (CORS, body parsing, rate limiting).
Part 7: React and HTTP Clients
React doesn't have built-in interceptors like Angular does. Instead, you use HTTP libraries like Axios or custom hooks wrapping the Fetch API.
Why Axios?
Axios is popular because:
- Simple interceptor syntax (built-in support)
- Automatic JSON transformation
- Request cancellation (useful for cleanup)
- Timeout support
Alternative: Fetch + Custom Hook
You can build interceptors with Fetch, but it requires more boilerplate. Here's the pattern:
// Custom HTTP client using Fetch
class HttpClient {
constructor() {
this.requestInterceptors = [];
this.responseInterceptors = [];
}
async request(url, options = {}) {
// Run request interceptors
let config = { url, ...options };
for (const interceptor of this.requestInterceptors) {
config = await interceptor(config);
}
// Make request
const response = await fetch(config.url, config);
// Run response interceptors
let result = response;
for (const interceptor of this.responseInterceptors) {
result = await interceptor(result);
}
return result;
}
}
For this guide, we'll focus on Axios because it's simpler and more widely used in React apps. The concepts apply to Fetch or other clients too.
Part 8: React + Axios Setup
Step 1: Install Axios
npm install axios
Step 2: Create an Axios Instance
// api/client.js
import axios from 'axios';
export const httpClient = axios.create({
baseURL: process.env.REACT_APP_API_URL,
timeout: 10000, // 10 second timeout
});
export default httpClient;
Why create an instance instead of using axios.default?
- You can have multiple HTTP clients with different interceptors (e.g., one for your API, one for third-party services)
- Easier to test (you can mock the instance)
- Cleaner separation of concerns
Step 3: Add Your First Interceptor
// api/client.js
import axios from 'axios';
const httpClient = axios.create({
baseURL: process.env.REACT_APP_API_URL,
});
// Request interceptor: add auth token
httpClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// If something fails *before* the request is sent, handle it here
return Promise.reject(error);
}
);
// Response interceptor: handle errors
httpClient.interceptors.response.use(
(response) => {
// Any 2xx status code comes here
return response.data; // Unwrap the data
},
(error) => {
// Any non-2xx status code comes here
console.error('API Error:', error.response?.status, error.message);
return Promise.reject(error);
}
);
export default httpClient;
Step 4: Use in Components
// components/UserList.jsx
import { useEffect, useState } from 'react';
import httpClient from '../api/client';
export default function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUsers = async () => {
setLoading(true);
try {
// httpClient automatically applies all interceptors
const data = await httpClient.get('/users');
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
That's it. The token is automatically included. Errors are automatically logged. Your component stays simple.
Part 9: Axios Request Interceptor Deep Dive
Adding Multiple Headers
httpClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
// Add auth
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add request ID for tracing (helps with debugging)
config.headers['X-Request-ID'] = generateUUID();
// Add API version
config.headers['X-API-Version'] = '2.0';
// Add user language preference
config.headers['Accept-Language'] = getUserLanguage();
return config;
});
Conditionally Adding Headers
// Only add auth headers for your own API, not for third-party requests
httpClient.interceptors.request.use((config) => {
const isInternalAPI = config.url.startsWith('/api');
if (isInternalAPI) {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
return config;
});
Logging Request Details
httpClient.interceptors.request.use((config) => {
const timestamp = new Date().toISOString();
const method = config.method.toUpperCase();
console.log(`[${timestamp}] 📤 ${method} ${config.url}`);
console.log('Headers:', config.headers);
if (config.data) console.log('Body:', config.data);
return config;
});
Handling Request Errors (Rare)
Request errors happen when:
- Headers are invalid
- Request can't be constructed
- Local validation fails
httpClient.interceptors.request.use(
(config) => {
// ✅ Happy path
return config;
},
(error) => {
// ❌ Error path — this rarely happens, but handle it
console.error('Request construction failed:', error);
return Promise.reject(error);
}
);
Part 10: Axios Response Interceptor Deep Dive
Unwrapping Nested API Responses
Most APIs wrap their responses. Your interceptor can flatten them:
// API returns: { success: true, data: [...], errors: null, meta: { page: 1 } }
// Your app should just get the array
httpClient.interceptors.response.use(
(response) => {
// Assume this shape and unwrap it
if (response.data?.data !== undefined) {
return response.data.data;
}
// Fallback to raw response
return response.data;
}
);
Logging Responses
httpClient.interceptors.response.use(
(response) => {
const method = response.config.method.toUpperCase();
const status = response.status;
const url = response.config.url;
console.log(`✅ ${status} ${method} ${url}`);
return response.data;
},
(error) => {
const method = error.config?.method?.toUpperCase() || 'UNKNOWN';
const status = error.response?.status || 'ERROR';
const url = error.config?.url || 'UNKNOWN';
console.error(`❌ ${status} ${method} ${url} - ${error.message}`);
return Promise.reject(error);
}
);
Transforming Response Data
// Transform timestamps to Date objects, format names, etc.
httpClient.interceptors.response.use((response) => {
const data = response.data;
// If it's an array of users, transform each
if (Array.isArray(data)) {
return data.map(transformUser);
}
// If it's a single user
if (data.id && data.name) {
return transformUser(data);
}
return data;
});
function transformUser(user) {
return {
...user,
createdAt: new Date(user.created_at), // Parse timestamp
displayName: user.name.toUpperCase(), // Format name
};
}
Part 11: Handling 401 (Unauthorized) in React
This is the production scenario everyone hits: your JWT token expires mid-session. The server rejects it with 401. What do you do?
Option A: Simple approach — just log them out
httpClient.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// Token expired — clear auth and redirect to login
localStorage.removeItem('access_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Problem: Jarring user experience. They lose their work.
Option B: Smart approach — refresh the token and retry
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
httpClient.interceptors.response.use(
(response) => response.data,
async (error) => {
const { config } = error;
// Only handle 401 for specific endpoints
if (error.response?.status === 401 && config.url !== '/auth/refresh') {
if (!isRefreshing) {
isRefreshing = true;
try {
// Make refresh request
const { data } = await axios.post(
`${process.env.REACT_APP_API_URL}/auth/refresh`,
{ refreshToken: localStorage.getItem('refresh_token') }
);
// Store new token
localStorage.setItem('access_token', data.accessToken);
// Update default header
httpClient.defaults.headers.common.Authorization =
`Bearer ${data.accessToken}`;
// Resolve queued requests with new token
processQueue(null, data.accessToken);
// Retry original request
config.headers.Authorization = `Bearer ${data.accessToken}`;
return httpClient(config);
} catch (refreshError) {
// Refresh failed — clear everything and log out
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
processQueue(refreshError, null);
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// If already refreshing, queue this request until refresh completes
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
config.headers.Authorization = `Bearer ${token}`;
return httpClient(config);
});
}
return Promise.reject(error);
}
);
This is more complex, but it's production-grade. Here's what happens:
- First 401 arrives → Start refresh, queue other failed requests
- More 401s arrive → They get queued, not all sent at once
- Refresh completes → Process the queue, retry all queued requests
- Refresh fails → Log out the user
Part 12: Token Refresh in React (Complete Example)
Here's a complete, copy-paste-ready setup:
// api/client.js
import axios from 'axios';
const httpClient = axios.create({
baseURL: process.env.REACT_APP_API_URL,
timeout: 10000,
});
// State for token refresh queue
let isRefreshing = false;
let refreshSubscribers = [];
// Subscribe to token refresh completion
const subscribeTokenRefresh = (callback) => {
refreshSubscribers.push(callback);
};
// Notify all subscribers that token was refreshed
const onRefreshed = (token) => {
refreshSubscribers.forEach(callback => callback(token));
refreshSubscribers = [];
};
// Request interceptor: add auth token
httpClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor: handle 401 and refresh
httpClient.interceptors.response.use(
(response) => response.data,
async (error) => {
const { config } = error;
// Not a 401, or it's the refresh endpoint itself
if (error.response?.status !== 401 || config.url.includes('/auth/refresh')) {
return Promise.reject(error);
}
if (!isRefreshing) {
isRefreshing = true;
try {
// Make the refresh request (use raw axios to avoid infinite loops)
const { data } = await axios.post(
`${process.env.REACT_APP_API_URL}/auth/refresh`,
{ refreshToken: localStorage.getItem('refresh_token') },
{ timeout: 10000 }
);
// Store new tokens
localStorage.setItem('access_token', data.accessToken);
if (data.refreshToken) {
localStorage.setItem('refresh_token', data.refreshToken);
}
// Update client's default header for future requests
httpClient.defaults.headers.common.Authorization =
`Bearer ${data.accessToken}`;
// Notify subscribers and clear the queue
onRefreshed(data.accessToken);
// Retry the original request
config.headers.Authorization = `Bearer ${data.accessToken}`;
return httpClient(config);
} catch (refreshError) {
// Refresh failed — clear tokens and redirect
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
onRefreshed(null); // Clear subscribers without token
// Redirect to login
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// While refresh is happening, queue this request
return new Promise((resolve) => {
subscribeTokenRefresh((token) => {
if (token) {
// Refresh succeeded, retry with new token
config.headers.Authorization = `Bearer ${token}`;
resolve(httpClient(config));
} else {
// Refresh failed, reject
resolve(Promise.reject(error));
}
});
});
}
);
export default httpClient;
Usage in a component:
// components/Dashboard.jsx
import { useEffect, useState } from 'react';
import httpClient from '../api/client';
export default function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
// If token expired, interceptor handles refresh automatically
const result = await httpClient.get('/api/dashboard');
setData(result);
} catch (error) {
// If refresh failed, we get an error
console.error('Failed to fetch dashboard:', error);
}
};
fetchData();
}, []);
return data ? <div>{/* render */}</div> : <p>Loading...</p>;
}
Part 13: Common React Interceptor Mistakes
Mistake 1: Adding Interceptors in Every Component
❌ Wrong:
// components/UserList.jsx
useEffect(() => {
const interceptor = httpClient.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`;
return config;
});
return () => {
httpClient.interceptors.response.eject(interceptor);
};
}, [token]);
This adds a new interceptor every time the component mounts. You'll have 50 interceptors by the end of the day.
✅ Right:
// api/client.js (once, at app startup)
httpClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
Set up interceptors once when the app starts, not in components.
Mistake 2: Infinite Refresh Loops
❌ Wrong:
httpClient.interceptors.response.use(
(response) => response.data,
(error) => {
// Every 401 tries to refresh, even if refresh itself gets 401
if (error.response?.status === 401) {
return refreshToken().then(() => httpClient(error.config));
}
return Promise.reject(error);
}
);
If refreshToken() returns 401 (refresh token expired), you loop forever.
✅ Right:
if (error.response?.status === 401 && !error.config.url.includes('/auth/refresh')) {
// Skip 401 handling if this *is* the refresh request
return refreshToken().then(() => httpClient(error.config));
}
Mistake 3: Not Unwrapping Responses
❌ Wrong:
// Your API returns { data: [...], errors: null, success: true }
// You map over response.data.data everywhere
const users = response.data.data.map(u => u.name);
Repeated everywhere. Inconsistent. Fragile.
✅ Right:
// In the response interceptor
httpClient.interceptors.response.use((response) => {
return response.data?.data || response.data;
});
// Now in components
const users = data.map(u => u.name); // Clean!
Mistake 4: Swallowing Errors
❌ Wrong:
httpClient.interceptors.response.use(
(response) => response.data,
(error) => response.data // 🚨 returns undefined!
);
❌ Also wrong:
httpClient.interceptors.response.use(
(response) => response.data,
(error) => null // Returns null instead of error
);
Errors disappear silently. Debugging becomes a nightmare.
✅ Right:
httpClient.interceptors.response.use(
(response) => response.data,
(error) => {
// Log it for debugging
console.error('API error:', error);
// Handle specific cases
if (error.response?.status === 401) {
// Handle auth
}
// Always reject for upstream handlers
return Promise.reject(error);
}
);
Mistake 5: Blocking on Interceptors
❌ Wrong:
httpClient.interceptors.request.use(async (config) => {
// Synchronous code, not async!
const token = await getTokenFromServer(); // ❌ blocks everything
config.headers.Authorization = `Bearer ${token}`;
return config;
});
Request interceptors should be synchronous (or resolve immediately). Async logic blocks all requests.
✅ Right:
// Pre-fetch tokens before the request
httpClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
config.headers.Authorization = `Bearer ${token}`;
return config;
});
Or refresh tokens proactively before they expire, not in the interceptor.
Part 14: Angular and HTTP Interceptors
Angular has first-class interceptor support baked into its HTTP client. Unlike React (where you add Axios), Angular's HttpClient is designed around interceptors from the ground up.
Angular Interceptor API
// An interceptor implements the HttpInterceptor interface
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Modify request
const authReq = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
// Pass to next interceptor, or network if this is the last one
return next.handle(authReq);
}
}
Key Angular concepts:
-
HttpRequest — immutable request object (you
.clone()it to modify) - HttpHandler — next step in the chain (could be another interceptor or the network)
- HttpEvent — response (could be a full response, a progress event, etc.)
- Observable — RxJS stream (you can pipe, retry, catch, etc.)
Registering Interceptors
// app.config.ts (Angular 14+) or app.module.ts
import { HttpInterceptor, provideHttpClient, withInterceptors } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor, errorInterceptor, loadingInterceptor])
),
],
};
Or with the old module-based approach:
// app.module.ts
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
@NgModule({
imports: [HttpClientModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
],
})
export class AppModule {}
Part 15: Angular Functional Interceptors (Modern Approach)
Angular 15+ introduced functional interceptors, which are simpler than class-based ones.
// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const token = authService.getToken();
if (token) {
// Clone and add Authorization header
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next(req);
};
Much simpler! No class boilerplate, no @Injectable(), no interface to implement.
Registration:
// app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([authInterceptor]) // Add as many as you want
),
],
};
Part 16: Modifying Angular Requests
Adding Headers
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem('access_token');
// HttpRequest is immutable — you clone it to modify
const authReq = req.clone({
setHeaders: {
Authorization: token ? `Bearer ${token}` : '',
'X-Request-ID': generateRequestId(),
'X-API-Version': '2.0',
}
});
return next(authReq);
};
Adding Query Parameters
export const versionInterceptor: HttpInterceptorFn = (req, next) => {
// Add v=2 to all requests
const versionedReq = req.clone({
setParams: {
v: '2'
}
});
return next(versionedReq);
};
Modifying URL
export const baseUrlInterceptor: HttpInterceptorFn = (req, next) => {
// Only for relative URLs
if (!req.url.startsWith('http')) {
const apiUrl = 'https://api.example.com';
req = req.clone({
url: `${apiUrl}${req.url}`
});
}
return next(req);
};
Modifying Body
export const bodyInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method === 'POST' && req.body) {
// Add timestamp to all POST requests
const modifiedBody = {
...req.body,
_timestamp: new Date().toISOString(),
};
req = req.clone({
body: modifiedBody
});
}
return next(req);
};
Part 17: Angular Response Handling
Response interceptors in Angular are handled via RxJS operators on the Observable:
import { tap, catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
console.log(`📤 ${req.method} ${req.url}`);
return next(req).pipe(
tap((event) => {
// Runs on successful response
if (event instanceof HttpResponse) {
console.log(`✅ ${event.status} ${event.statusText}`);
}
}),
catchError((error) => {
// Runs on error
console.error(`❌ ${error.status} ${error.statusText}`);
return throwError(() => error);
})
);
};
Unwrapping API Responses
import { map } from 'rxjs/operators';
export const unwrapInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
map((event) => {
// Only transform the actual response, not upload/download progress events
if (event instanceof HttpResponse) {
// Unwrap from API response structure
return event.clone({
body: event.body?.data || event.body
});
}
return event;
})
);
};
Transforming Data
export const transformInterceptor: HttpInterceptorFn = (req, next) => {
return next(req).pipe(
map((event) => {
if (event instanceof HttpResponse && Array.isArray(event.body)) {
// Transform each item
return event.clone({
body: event.body.map(item => ({
...item,
createdAt: new Date(item.created_at),
}))
});
}
return event;
})
);
};
Part 18: Multiple Angular Interceptors and Ordering
Order matters. Interceptors run in the order they're registered:
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withInterceptors([
// 1. First: add request ID for tracing
requestIdInterceptor,
// 2. Second: add auth token
authInterceptor,
// 3. Third: log requests
loggingInterceptor,
// 4. Network request happens here
// 5. Fourth (reverse): handle errors globally
errorInterceptor,
// 6. Fifth (reverse): transform responses
transformInterceptor,
])
),
],
};
Request flow (↓):
Component
↓
requestIdInterceptor (add X-Request-ID)
↓
authInterceptor (add Authorization)
↓
loggingInterceptor (log to console)
↓
Network
Response flow (↑):
Network Response
↓
transformInterceptor (unwrap/transform data)
↓
errorInterceptor (catch/retry)
↓
loggingInterceptor (log response)
↓
Component
Part 19: Authentication Interceptor (Angular)
// interceptors/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
// Get token (could be from localStorage, service, etc.)
const token = authService.getAccessToken();
if (token) {
// Clone and add Authorization header
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next(req);
};
Service:
// services/auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable({ providedIn: 'root' })
export class AuthService {
constructor(private http: HttpClient) {}
getAccessToken(): string | null {
return localStorage.getItem('access_token');
}
refreshToken(): Observable<{ accessToken: string }> {
const refreshToken = localStorage.getItem('refresh_token');
return this.http.post<{ accessToken: string }>('/auth/refresh', {
refreshToken
});
}
}
Part 20: Error Interceptor (Angular)
// interceptors/error.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { Router } from '@angular/router';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const router = inject(Router);
return next(req).pipe(
catchError((error) => {
// Handle specific error codes
if (error.status === 401) {
// Token expired
authService.logout();
router.navigate(['/login']);
} else if (error.status === 403) {
// Forbidden
router.navigate(['/forbidden']);
} else if (error.status >= 500) {
// Server error
console.error('Server error:', error);
}
return throwError(() => error);
})
);
};
Part 21: Loading Interceptor (Angular)
// interceptors/loading.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { tap } from 'rxjs/operators';
import { LoadingService } from '../services/loading.service';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loadingService = inject(LoadingService);
// Start loading
loadingService.show();
return next(req).pipe(
tap(
() => {
// Response received
loadingService.hide();
},
(error) => {
// Error
loadingService.hide();
}
)
);
};
Service:
// services/loading.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class LoadingService {
private loadingCount = 0;
isLoading$ = new BehaviorSubject(false);
show(): void {
this.loadingCount++;
this.isLoading$.next(true);
}
hide(): void {
this.loadingCount = Math.max(0, this.loadingCount - 1);
if (this.loadingCount === 0) {
this.isLoading$.next(false);
}
}
}
Component:
// components/loading-bar.component.ts
import { Component } from '@angular/core';
import { LoadingService } from '../services/loading.service';
@Component({
selector: 'app-loading-bar',
template: `
<div *ngIf="loadingService.isLoading$ | async" class="loading-bar">
Loading...
</div>
`,
})
export class LoadingBarComponent {
constructor(public loadingService: LoadingService) {}
}
Part 22: Retry Interceptor (Angular)
// interceptors/retry.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { catchError, retry, delay } from 'rxjs/operators';
import { throwError } from 'rxjs';
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
// Only retry GET requests on server errors (5xx)
if (req.method === 'GET') {
return next(req).pipe(
retry({
count: 3,
delay: (error) => {
// Only retry on 5xx, not on client errors
if (error.status >= 500) {
console.log(`Retrying failed request: ${req.url}`);
return timer(1000); // Wait 1 second before retry
}
return throwError(() => error);
}
})
);
}
return next(req);
};
With exponential backoff:
import { timer } from 'rxjs';
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
if (req.method === 'GET') {
return next(req).pipe(
retry({
count: 3,
delay: (error, retryCount) => {
if (error.status >= 500) {
const waitTime = Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s
console.log(`Retrying in ${waitTime}ms...`);
return timer(waitTime);
}
return throwError(() => error);
}
})
);
}
return next(req);
};
Part 23: Request-Specific Interceptor Behavior
Sometimes you want an interceptor to behave differently for specific requests. Two approaches:
Approach 1: Check URL
export const authInterceptor: HttpInterceptorFn = (req, next) => {
// Only add auth for your API, not for third-party services
const isInternalAPI = req.url.includes('/api/');
if (isInternalAPI) {
const token = localStorage.getItem('access_token');
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
}
return next(req);
};
Approach 2: Use Custom Headers as Flags
In your component:
// component.ts
this.http.get('/api/users', {
headers: {
'X-Skip-Auth': 'true' // Custom flag
}
}).subscribe(...);
In the interceptor:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (req.headers.has('X-Skip-Auth')) {
// Remove the flag and skip auth
return next(req.clone({
headers: req.headers.delete('X-Skip-Auth')
}));
}
const token = localStorage.getItem('access_token');
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};
Part 24: Caching Interceptor (Angular)
// interceptors/cache.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
class CacheService {
private cache = new Map<string, any>();
get(key: string): any {
return this.cache.get(key);
}
set(key: string, value: any): void {
this.cache.set(key, value);
}
has(key: string): boolean {
return this.cache.has(key);
}
}
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
const cacheService = inject(CacheService);
// Only cache GET requests
if (req.method !== 'GET') {
return next(req);
}
// Check cache
if (cacheService.has(req.url)) {
console.log(`Cache hit: ${req.url}`);
return of(cacheService.get(req.url));
}
// Not in cache — fetch and cache
return next(req).pipe(
tap((event) => {
if (event instanceof HttpResponse) {
cacheService.set(req.url, event);
}
})
);
};
Part 25: Complex Token Refresh (Angular)
// interceptors/token-refresh.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { BehaviorSubject, filter, take, switchMap, catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
// Shared subject for refresh state
const isRefreshing$ = new BehaviorSubject(false);
export const tokenRefreshInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
return next(req).pipe(
catchError((error) => {
// Only handle 401 and skip if it's the refresh endpoint itself
if (error.status !== 401 || req.url.includes('/auth/refresh')) {
return throwError(() => error);
}
if (!isRefreshing$.value) {
isRefreshing$.next(true);
return authService.refreshToken().pipe(
switchMap((response) => {
isRefreshing$.next(false);
// Retry the original request with new token
return next(req.clone({
setHeaders: {
Authorization: `Bearer ${response.accessToken}`
}
}));
}),
catchError((refreshError) => {
isRefreshing$.next(false);
authService.logout();
return throwError(() => refreshError);
})
);
}
// If already refreshing, wait for it to complete
return isRefreshing$.pipe(
filter(value => !value), // Wait until refresh completes
take(1),
switchMap(() => {
// Retry with updated token
const newToken = authService.getAccessToken();
return next(req.clone({
setHeaders: {
Authorization: `Bearer ${newToken}`
}
}));
})
);
})
);
};
Part 26: Interceptor Limitations
Interceptors are powerful, but they have limits.
❌ Can't intercept:
- Requests from other tabs/windows — Interceptors are per-instance
- WebSocket connections — Different protocol, different API
- Worker/Service Worker requests — Different thread context
-
Form submissions — Use
<form>element directly, not XHR - GraphQL subscriptions — Real-time, not interceptable like regular HTTP
- CORS pre-flight requests — OPTIONS requests handled by browser, not your code
❌ Limited scope:
- Large file uploads — Interceptors see the whole request, can't stream progress easily
- Streaming responses — You intercept the whole response, not chunks
- Cancellation — Some interceptors can't properly cancel in-flight requests
✅ Workarounds:
For WebSocket auth:
// Add token to WebSocket handshake manually
const token = localStorage.getItem('access_token');
const socket = io('https://api.example.com', {
auth: { token }
});
For file uploads with progress:
// Use progress events, not interceptors
const formData = new FormData();
formData.append('file', file);
this.http.post('/upload', formData, {
reportProgress: true,
observe: 'events'
}).subscribe((event) => {
if (event.type === HttpEventType.UploadProgress) {
const percentDone = (event.loaded / event.total) * 100;
console.log(`Uploaded ${percentDone}%`);
}
});
Part 27: Production Architecture
Here's how a production app structures interceptors:
src/
├── api/
│ ├── client.ts # Axios instance (React)
│ ├── http.service.ts # HttpClient setup (Angular)
│ └── endpoints.ts # API endpoint definitions
│
├── interceptors/
│ ├── auth.interceptor.ts # Add JWT tokens
│ ├── error.interceptor.ts # Global error handling
│ ├── loading.interceptor.ts # Loading state
│ ├── retry.interceptor.ts # Retry failed requests
│ ├── transform.interceptor.ts # Unwrap responses
│ ├── logging.interceptor.ts # Development logging
│ └── index.ts # Export all
│
├── services/
│ ├── auth.service.ts # Token management
│ ├── loading.service.ts # Global loading state
│ └── error.service.ts # Error toasts/notifications
│
└── App.tsx (React) / app.config.ts (Angular)
Best practices:
- One interceptor per concern — Auth, errors, loading, retries, etc. each get their own
- Service-backed — Interceptors delegate to services for state management
- Minimal logic — Keep interceptors thin; put business logic in services
- Testable — Services are easier to test than interceptor chains
- Documented order — Comments showing interceptor execution sequence
- Error handling — Every error interceptor must rethrow or handle explicitly
- No side effects — Avoid async operations in request interceptors if possible
Example well-structured setup:
// app.config.ts (Angular)
export const appConfig: ApplicationConfig = {
providers: [
// HTTP with interceptors in order
provideHttpClient(
withInterceptors([
// Layer 1: Request preparation
requestIdInterceptor, // Add tracing ID
authInterceptor, // Add auth token
contentTypeInterceptor, // Add content-type
// Layer 2: Response handling (reverse order on response)
errorInterceptor, // Catch and handle errors
transformInterceptor, // Unwrap responses
cacheInterceptor, // Cache GET responses
])
),
// Services
AuthService,
ErrorService,
LoadingService,
CacheService,
],
};
Part 28: React vs Angular Comparison
| Aspect | React (Axios) | Angular (HttpClient) |
|---|---|---|
| Setup | Create instance + add interceptors | Register via provider or module |
| Syntax | httpClient.interceptors.request.use() |
Functional export const or class-based |
| Request flow | Synchronous middleware chain | Observable-based with RxJS |
| Error handling | Promises and .catch()
|
RxJS catchError operator |
| Typing | Generics on Axios methods | Strong TypeScript support with generics |
| State sharing | External state management (Zustand, Redux) | RxJS Subjects/Services |
| Learning curve | Easier for Promises-based developers | Steeper, requires RxJS knowledge |
| Built-in features | Fewer, more DIY | Complete HTTP toolkit |
| Testing | Mock axios instance | Mock HttpTestingController |
| Multiple instances | Can have multiple axios clients | One HttpClient per module/config |
When to use each:
- React + Axios: You like simplicity, prefer Promises, want full control
- Angular HttpClient: You're in Angular, need RxJS, want built-in patterns
Part 29: Final Mental Model
Think of HTTP interceptors as a request/response gateway:
┌─────────────────────────────────────────────────────────────┐
│ Your App │
├─────────────────────────────────────────────────────────────┤
│
│ Interceptor Chain = Security + Transformation Pipeline
│
│ Request Path: Auth → Logging → Error Handling → Network
│ Response Path: Network → Transform → Logging → App
│
└─────────────────────────────────────────────────────────────┘
Key mental models:
- Interceptors run in sequence — First added, first to run (on requests)
- Responses run in reverse — Last added, first to run (on responses)
- Each interceptor either passes it along or handles it — No silent failures
- Immutable requests — Clone and return, don't mutate
- Errors can be handled or rethrown — You're in control
Conclusion
HTTP interceptors are one of those architectural patterns that seem simple at first ("just add a header to every request") but reveal their power once you understand the full flow. With interceptors, you move cross-cutting concerns out of your components and into a centralized, testable layer.
In React: Axios interceptors are straightforward Promise-based middleware. Perfect for adding auth tokens, handling 401 refreshes, and global error handling.
In Angular: HttpClient interceptors are RxJS-native, giving you the full power of reactive streams. Slightly more complex syntax, but incredibly flexible once you grok Observables.
The production pattern: One interceptor per concern (auth, errors, loading, retry, transform). Each interceptor is thin and delegates to a service for state management. This keeps your code testable, maintainable, and sane.
Start simple — add auth, then unwrap responses, then handle 401s. Each addition teaches you something new about the flow. Before long, you'll have a rock-solid HTTP layer that makes your entire app more reliable.
Now go build something great.

Top comments (0)