Intercept HTTP Requests in Modern JavaScript
Every web application communicates with APIs. But what happens when you need to intercept those requests--not just to make them, but to modify them, log them, or handle their responses globally? HTTP request interception is a powerful pattern that separates sophisticated applications from basic implementations. Whether you're automatically attaching authentication tokens, implementing sophisticated caching strategies, or debugging API calls in production, understanding how to intercept HTTP requests is essential for building maintainable, observable JavaScript applications.
Why Intercept HTTP Requests
The value of HTTP interception becomes apparent when you face problems that affect multiple API calls simultaneously. Consider authentication: when your token expires, you need to refresh it and retry all failing requests. Without interception, you'd handle this in every individual API call, creating maintenance nightmares and inconsistent error handling. With interception, you centralize this logic in one place--your request or response interceptor--and every API call in your application automatically benefits from the improved handling.
Request interception also excels at observability. When debugging production issues, you need consistent logging across all API activity. Interceptors let you capture request timing, response status codes, and payload sizes without modifying the actual API call code. This means your debugging instrumentation doesn't pollute your application logic, and you get comprehensive network visibility without the clutter. The same principle applies to analytics tracking, where you might want to measure API call frequency, duration, and outcomes across your entire application.
Response interception is equally powerful for handling errors consistently. Instead of wrapping every fetch call in try-catch blocks with similar error handling logic, a response interceptor can catch all error responses, log them comprehensively, display user-friendly messages, and even trigger retry logic automatically. This approach reduces code duplication while ensuring your users see consistent error experiences regardless of which API endpoint failed. For applications that depend heavily on external APIs--virtually all modern web applications--this consistency is crucial for user experience.
The Core Interception Approaches
Modern JavaScript offers several approaches to HTTP interception, each with distinct advantages:
- Fetch API monkey patching for native fetch users who want to avoid external dependencies
- Axios interceptors for projects using the Axios library with built-in interceptor support
- Service workers for network-level interception with caching and offline capabilities
- Next.js middleware for server-side request handling at the edge
1. Intercepting the Native Fetch API
The Fetch API is the modern standard for HTTP requests in JavaScript, but unlike Axios, it doesn't provide built-in interceptor support. To add interception capabilities, you need to "monkey patch" the global fetch function by wrapping it with your own implementation that executes your interception logic before and after the actual request. This approach works because JavaScript allows you to reassign global functions, and your wrapped version can maintain the same interface as the original.
The most common pattern involves storing a reference to the original fetch function, then replacing the global fetch with your custom version that adds interception capabilities. Your custom fetch first runs any request interceptors, then calls the original fetch with the potentially modified request, and finally runs response interceptors before returning the result. This creates a layer of indirection that your entire application can use without any code changes to existing API calls--simply by using fetch as normal, you get automatic interception.
// Store the original fetch implementation
const originalFetch = window.fetch;
// Request interceptor function
const requestInterceptors = [];
const responseInterceptors = [];
// Add a request interceptor
function addRequestInterceptor(interceptor) {
requestInterceptors.push(interceptor);
}
// Add a response interceptor
function addResponseInterceptor(interceptor) {
responseInterceptors.push(interceptor);
}
// Create the intercepted fetch
window.fetch = async function(url, options = {}) {
// Run request interceptors in sequence
let modifiedOptions = { ...options };
for (const interceptor of requestInterceptors) {
modifiedOptions = await interceptor(modifiedOptions, url);
}
try {
const response = await originalFetch(url, modifiedOptions);
// Create a wrapped response for interception
const wrappedResponse = {
...response,
data: null,
async json() {
const data = await response.json();
this.data = data;
return data;
},
async text() {
const data = await response.text();
this.data = data;
return data;
}
};
// Run response interceptors in sequence
for (const interceptor of responseInterceptors) {
await interceptor(wrappedResponse, url);
}
return wrappedResponse;
} catch (error) {
// Run response interceptors even on error
for (const interceptor of responseInterceptors) {
await interceptor({ ok: false, status: 0, data: null, error }, url);
}
throw error;
}
};
This pattern becomes even more powerful when you add automatic authentication token injection. Every request that uses fetch will automatically receive the current auth token without any individual API call needing to handle token management. Similarly, a response interceptor can detect 401 unauthorized responses, trigger a token refresh, and retry the original request--all transparently to the calling code. The key insight is that interception moves network concerns out of your business logic and into a centralized layer where they can be maintained consistently. For React applications with complex state management, this pattern is particularly valuable as it keeps authentication logic separate from UI components.
Using the fetch-intercept Library
Rather than implementing monkey patching yourself, the fetch-intercept library provides a clean, documented API for registering request and response interceptors. This library handles the complexity of maintaining the original fetch behavior while providing hooks for your interception logic. It's a pragmatic choice for projects that need interception capabilities without the maintenance burden of custom implementations.
import { register } from 'fetch-intercept';
const unregister = register({
request: (url, config) => {
// Add auth token to every request
const token = localStorage.getItem('authToken');
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
console.log(`[Request] ${config.method?.toUpperCase()} ${url}`);
return [url, config];
},
response: (response) => {
console.log(`[Response] ${response.status} ${response.url}`);
// Add response time header for monitoring
const responseTime = Date.now() - response.requestStartTime;
response.headers.set('X-Response-Time', `${responseTime}ms`);
return response;
}
});
// Later, to remove interceptors
// unregister();
The library's design follows the interceptor pattern familiar from Axios, making it accessible to developers coming from that ecosystem. It also handles edge cases that custom implementations might miss, such as ensuring interceptors don't break the fetch promise chain and that errors in interceptors don't crash the entire request pipeline. For production applications, using a well-tested library reduces the risk of subtle bugs in your network layer.
2. Axios Interceptors: Built-in Solution
Axios has long been the preferred choice for projects requiring straightforward interceptor support, and it remains relevant in 2025 for applications where its feature set aligns with project needs. The library includes native interceptor capabilities that don't require monkey patching or external libraries--simply register functions that transform requests before sending and responses before returning to your code. This built-in support makes Axios particularly attractive for teams prioritizing development velocity over bundle size.
Request interceptors in Axios receive the config object before each request is sent, allowing you to modify headers, transform payloads, or even cancel the request entirely by throwing an error. Response interceptors receive the complete response object, including the data that Axios has already parsed. Both interceptor types can be synchronous or asynchronous, giving you flexibility in handling operations that require waiting--such as refreshing an expired authentication token before proceeding with the original request.
import axios from 'axios';
// Request interceptor - runs before each request is sent
axios.interceptors.request.use(
(config) => {
// Add timestamp for performance monitoring
config.metadata = { startTime: Date.now() };
// Get auth token from storage
const token = sessionStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log(`[Axios Request] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor - runs before response is returned to caller
axios.interceptors.response.use(
(response) => {
// Calculate and log response time
const duration = Date.now() - response.config.metadata.startTime;
console.log(`[Axios Response] ${response.status} in ${duration}ms`);
// Transform successful responses if needed
return response;
},
async (error) => {
// Handle 401 Unauthorized - attempt token refresh
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const response = await axios.post('/api/auth/refresh', { refreshToken });
const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);
// Retry the original request with new token
error.config.headers.Authorization = `Bearer ${accessToken}`;
return axios(error.config);
} catch (refreshError) {
// Redirect to login on refresh failure
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
// Log all errors for monitoring
console.error('[Axios Error]', error.config?.url, error.response?.status);
return Promise.reject(error);
}
);
The response interceptor example demonstrates one of Axios's most powerful patterns: automatic token refresh and request retry. When a request fails with a 401 Unauthorized status, the interceptor attempts to refresh the authentication token using a stored refresh token. If successful, it automatically retries the original request with the new access token. This entire flow happens transparently--the API call in your application code simply fails once (if the token was expired) and then succeeds on retry, without any special handling required in the calling code.
3. Service Worker Interception Patterns
Service workers provide a fundamentally different interception model that operates at the network level, between your web application and the network itself. Unlike fetch monkey patching or Axios interceptors, service workers can intercept requests from any source on your page--including third-party scripts and resources you don't control. This makes service workers essential for implementing sophisticated caching strategies, offline support, and network request manipulation that affects your entire page.
The service worker lifecycle involves installation, activation, and then intercepting fetch events. When your service worker is active, every network request from your page triggers a fetch event that your service worker can handle. You can choose to respond from cache, forward to the network, or implement complex strategies like stale-while-revalidate that serve cached content immediately while updating from the network in the background. This capability is the foundation of Progressive Web App offline functionality and advanced caching architectures.
// service-worker.js
const CACHE_NAME = 'api-cache-v1';
const STATIC_CACHE = 'static-v1';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/styles.css',
'/app.js'
]);
})
);
self.skipWaiting(); // Activate immediately
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// API request strategy: Network first, falling back to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then((response) => {
// Clone and cache successful API responses
const clonedResponse = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, clonedResponse);
});
return response;
})
.catch(async () => {
// Network failed, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Return offline response for API calls
return new Response(
JSON.stringify({ error: 'Offline', cached: false }),
{ headers: { 'Content-Type': 'application/json' } }
);
})
);
return;
}
// Static asset strategy: Cache first, then network
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
// Return cached version and update in background
event.waitUntil(
fetch(request).then((networkResponse) => {
caches.open(STATIC_CACHE).then((cache) => {
cache.put(request, networkResponse);
});
}).catch(() => {})
);
return cachedResponse;
}
// Not in cache, fetch from network
return fetch(request).then((response) => {
const clonedResponse = response.clone();
caches.open(STATIC_CACHE).then((cache) => {
cache.put(request, clonedResponse);
});
return response;
});
})
);
});
This service worker example implements multiple caching strategies tailored to different resource types. API calls use a network-first strategy that prioritizes fresh data but falls back to cached responses when offline. Static assets use a cache-first strategy that serves instantly from cache while updating in the background. These sophisticated patterns would be impossible to implement with simple fetch monkey patching, demonstrating why service workers are essential for production-quality web applications that need reliable performance and offline support.
4. Next.js API Route Interception
Next.js applications have unique interception opportunities through both client-side patterns and server-side API routes. On the client, you can use the same fetch monkey patching or Axios interceptors as any React application. But Next.js also provides server-side interception capabilities through API routes and middleware that can transform requests before they reach your backend logic or external APIs.
Next.js middleware runs before requests complete, allowing you to modify request headers, rewrite URLs, redirect requests, or even block certain requests entirely. This is particularly useful for authentication checks, A/B testing, and geolocation-based content delivery. Unlike client-side interception, middleware runs on the edge, making it extremely fast and suitable for security-related checks that shouldn't depend on client-side JavaScript execution.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Protected route check
if (pathname.startsWith('/dashboard')) {
const token = request.cookies.get('authToken');
if (!token) {
// Redirect to login if not authenticated
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
// Verify token validity with edge function
const verifyResponse = await fetch(new URL('/api/auth/verify', request.url), {
headers: { Authorization: `Bearer ${token}` }
});
if (!verifyResponse.ok) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('expired', 'true');
return NextResponse.redirect(loginUrl);
}
}
// Add security headers to all responses
const response = NextResponse.next();
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Add user tracking header for analytics
const userId = request.cookies.get('userId');
if (userId) {
response.headers.set('X-User-ID', userId);
}
return response;
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*']
};
For server-side API routes in Next.js, you can create wrapper functions that automatically handle common concerns like authentication, logging, and error handling across all your API endpoints. This approach centralizes API logic similar to how interceptors work on the client, but operates at the server level where you have full control over the request/response cycle. When building AI-powered applications that rely on external APIs, this pattern ensures consistent error handling and request tracking across your entire infrastructure.
5. Best Practices for HTTP Interception
Effective HTTP interception requires careful attention to error handling, performance, and maintainability. Interceptors operate in critical paths--if your interceptor code throws an error, it can break the entire request pipeline. Always wrap interceptor logic in try-catch blocks and consider how errors should propagate. For request interceptors, you might choose to let errors propagate (causing the request to fail) or handle them gracefully and return modified config. Response interceptors should similarly consider whether errors should propagate to the caller or be handled internally.
Performance considerations are particularly important for request interceptors that run on every API call. Avoid synchronous operations that block request processing, and be cautious about asynchronous operations that add latency to every request. If you need to perform slow operations (like token refresh checks), consider caching results rather than checking on every request. Response interceptors have more flexibility since they're processing after the network call completes, but they still affect the total time before your application code receives the response.
// Example: Robust interceptor with proper error handling and performance considerations
const requestInterceptor = async (config, url) => {
try {
// Fast, synchronous operations only
const enrichedConfig = { ...config };
// Add cached auth token (synchronous read)
const token = authTokenCache.get();
if (token) {
enrichedConfig.headers = {
...enrichedConfig.headers,
Authorization: `Bearer ${token}`
};
}
// Only refresh token if needed (async, but cached result used)
if (shouldRefreshToken()) {
// This runs asynchronously and doesn't block the request
refreshTokenInBackground();
}
return enrichedConfig;
} catch (error) {
// Log but don't block request on interceptor errors
console.error('Request interceptor error:', error);
return config; // Return unmodified config
}
};
const responseInterceptor = async (response) => {
try {
// Log response metrics
const duration = Date.now() - response.config.metadata.startTime;
performanceLogger.recordResponse(response.url, response.status, duration);
// Transform response data if needed
if (response.config.transformResponse) {
response.data = response.config.transformResponse(response.data);
}
return response;
} catch (error) {
console.error('Response interceptor error:', error);
// Return original response on interceptor error
return response;
}
};
Documentation and testing are often overlooked aspects of interceptor implementation. Since interceptors affect all network traffic, bugs can have widespread effects. Document your interceptor chain clearly, including the order of execution and what each interceptor does. Write tests that verify interceptor behavior for both success and failure cases. Consider using named functions for interceptors rather than anonymous functions--this improves stack traces during debugging and makes it clearer what each interceptor is responsible for.
6. Performance Considerations
HTTP interception adds overhead to every network request, and this overhead can accumulate into noticeable performance impact if interceptors are inefficient. Request interceptors run synchronously before the network request begins--any delay here directly increases the time until the request starts. Response interceptors run after the response arrives but before your application code receives it--delays here increase total request latency. For optimal performance, keep interceptor logic as fast as possible and defer slow operations to asynchronous handlers that don't block the main thread.
Bundle size is another consideration, particularly for client-side applications. Axios adds approximately 30KB to your bundle compared to using native fetch with custom interception. For many applications, this trade-off is acceptable given Axios's convenience and reliability. However, if bundle size is critical, consider using native fetch with a minimal custom implementation or a lightweight interception library like fetch-intercept that adds only a few kilobytes.
Memory management becomes important with long-running applications that use interceptors extensively. Interceptors that store references to request or response objects can prevent garbage collection, leading to memory leaks over time. Ensure your interceptors don't maintain unnecessary references to response data after processing is complete. For single-page applications that run continuously, periodically review interceptor memory usage and ensure old interceptors are properly cleaned up when components unmount or application state changes.
Ready to build sophisticated web applications with clean, maintainable network layers? Our web development team specializes in building high-performance applications with modern JavaScript patterns.
Choose the right method for your project
Fetch Monkey Patching
Wrap the native Fetch API for interceptor support without external dependencies. Best for minimal bundle size.
Axios Interceptors
Built-in interceptor support with automatic token refresh, error handling, and request transformation. Mature and reliable.
Service Workers
Network-level interception for caching, offline support, and progressive web app functionality.
Next.js Middleware
Edge-based request handling for authentication, A/B testing, and security headers before requests reach your code.
1const originalFetch = window.fetch;2 3const requestInterceptors = [];4const responseInterceptors = [];5 6function addRequestInterceptor(interceptor) {7 requestInterceptors.push(interceptor);8}9 10function addResponseInterceptor(interceptor) {11 responseInterceptors.push(interceptor);12}13 14window.fetch = async function(url, options = {}) {15 let modifiedOptions = { ...options };16 17 // Run request interceptors18 for (const interceptor of requestInterceptors) {19 modifiedOptions = await interceptor(modifiedOptions, url);20 }21 22 try {23 const response = await originalFetch(url, modifiedOptions);24 const wrappedResponse = {25 ...response,26 async json() {27 const data = await response.json();28 this.data = data;29 return data;30 }31 };32 33 // Run response interceptors34 for (const interceptor of responseInterceptors) {35 await interceptor(wrappedResponse, url);36 }37 38 return wrappedResponse;39 } catch (error) {40 for (const interceptor of responseInterceptors) {41 await interceptor({ ok: false, error }, url);42 }43 throw error;44 }45};Frequently Asked Questions
Should I use Fetch with monkey patching or Axios with interceptors?
Choose Axios if you want built-in interceptor support and don't mind the ~30KB bundle size. Choose Fetch with monkey patching if bundle size is critical or you're already using native fetch throughout your application.
How do interceptors affect performance?
Interceptors add minimal overhead to each request. Request interceptors run synchronously before the network call, so keep them fast. Response interceptors run after the response arrives and have less impact on perceived performance.
Can I use multiple interceptors?
Yes. Most implementations support a chain of interceptors that run in order. Request interceptors typically run in registration order, while response interceptors run in reverse order (last registered = first executed).
What's the difference between service workers and fetch interceptors?
Fetch interceptors (monkey patching or Axios) run in your application JavaScript context. Service workers run in a separate thread and can intercept requests from any source on your page, including third-party scripts, making them essential for caching and offline support.