Why Intercept Fetch Requests?
Modern web applications frequently need to modify HTTP requests before they leave the browser or transform responses before they're processed by application code. Whether you're adding authentication headers, logging request data for debugging, transforming response formats, or handling errors consistently across your application, intercepting fetch requests provides a powerful mechanism for implementing these cross-cutting concerns.
Unlike Axios, which includes interceptors as a built-in feature, the native Fetch API requires a custom approach--but one that offers complete control and flexibility.
The Challenge with Native Fetch
The Fetch API is a low-level, promise-based API designed for simplicity and flexibility. However, this simplicity means there are no built-in request or response interception hooks. Developers must use monkey patching or wrapper functions to achieve similar functionality. This approach provides greater flexibility but also requires more responsibility from the developer to implement patterns correctly.
The most common use cases for fetch interception include authentication token injection, where you automatically add Bearer tokens or API keys to every outgoing request without manually including them in each fetch call. Request and response logging helps with debugging by capturing timing, headers, and body content for monitoring and troubleshooting. Error handling consistency ensures that HTTP error statuses are transformed into rejectable promises, matching the behavior developers expect from other HTTP libraries. Data transformation allows you to normalize API responses, add computed fields, or prepare data for your application's needs before components process it. Caching strategies implemented at the interceptor level can reduce network requests and improve application performance.
These cross-cutting concerns benefit from centralization--implementing them once in an interceptor means you don't need to remember to handle them in every individual API call throughout your codebase. For teams building web applications, this approach significantly reduces code duplication and ensures consistent behavior across all API interactions.
Monkey Patching Technique
Master the core technique for intercepting native fetch by overriding window.fetch while preserving original functionality.
Request Interceptors
Add authentication headers, log request data, modify URLs, and transform request bodies before sending.
Response Interceptors
Transform response data, add metadata, and handle errors consistently across your application.
Error Handling
Implement comprehensive error handling for network failures, HTTP errors, and edge cases.
Performance Optimization
Minimize interceptor overhead and implement conditional interception for optimal performance.
Next.js Integration
Apply fetch interception patterns in Next.js applications with client-side and server-side considerations.
The Monkey Patching Technique
The fundamental approach to intercepting fetch requests involves storing a reference to the original fetch function and then overriding window.fetch with a custom implementation. This technique allows you to add interception logic while preserving all original fetch behavior.
Basic Fetch Interceptor Structure
The core pattern involves three main steps: storing the original fetch, overriding the global fetch function, and calling the original fetch within your custom implementation. This approach is sometimes called "monkey patching" because it modifies an existing function at runtime.
// Step 1: Store the original fetch function before overriding
// This preserves access to the native implementation
const { fetch: originalFetch } = window;
// Step 2: Override window.fetch with your custom implementation
// This replaces the global fetch with your intercepted version
window.fetch = async (...args) => {
// Destructure args to get resource URL and config object
const [resource, config = {}] = args;
// Step 3a: REQUEST INTERCEPTOR
// Execute any logic before the actual request
// Common operations: logging, header modification, URL changes
console.log('Intercepted request to:', resource);
// Step 4: Call the original fetch to perform the actual network request
// This preserves all native fetch behavior
const response = await originalFetch(resource, config);
// Step 5b: RESPONSE INTERCEPTOR
// Execute any logic after receiving the response
// Common operations: logging, error checking, data transformation
console.log('Received response:', response.status);
// Step 6: Return the response to the caller
// The calling code receives the response as if fetch was never modified
return response;
};
How Interception Works
The interceptor operates as a middleware layer between your application code and the network. When your code calls fetch, it actually calls your custom function first. Your interceptor can modify the request arguments, log information, or even short-circuit and return a cached response. Then the original fetch is called, and the response passes back through your interceptor, where you can transform it before returning it to your application.
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Application │────▶│ Fetch Interceptor │────▶│ Original Fetch │
│ Code │◀────│ (Your Custom Code) │◀────│ (Native API) │
└─────────────────┘ └──────────────────────┘ └─────────────────┘
│ │
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ Request │ │ Network │
│ Transform│ │ Request │
└──────────┘ └──────────────┘
│
▼
┌──────────────┐
│ Network │
│ Response │
└──────────────┘
│
┌───────────────────────────────┘
▼
┌──────────┐ ┌──────────────┐
│ Response │ │ Application │
│ Transform│──────────────▶│ Receives │
└──────────┘ │ Response │
└──────────────┘
This flow allows you to implement sophisticated request and response handling without modifying any of your application code that makes API calls.
1// Store the original fetch function2const { fetch: originalFetch } = window;3 4// Override with custom implementation5window.fetch = async (...args) => {6 const [resource, config = {}] = args;7 8 // Request interceptor logic here9 console.log('Request:', resource, config);10 11 // Call original fetch12 const response = await originalFetch(resource, config);13 14 // Response interceptor logic here15 console.log('Response:', response);16 17 return response;18};Request Interceptors
Request interceptors execute before the actual network request is made, giving you the opportunity to modify the request in various ways. This section covers the most common patterns for request interception.
Adding Authentication Headers
The most frequent use case for request interception is adding authentication tokens to outgoing requests. This ensures that every API call includes the necessary credentials without requiring manual token injection in each fetch call.
window.fetch = async (...args) => {
let [resource, config = {}] = args;
// Only modify if not already authenticated
if (!config.headers || !config.headers.Authorization) {
// Retrieve token from storage
const token = localStorage.getItem('authToken');
if (token) {
// Merge new headers with existing ones
config = {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': config.headers?.['Content-Type'] || 'application/json'
}
};
}
}
const response = await originalFetch(resource, config);
return response;
};
When adding headers, it's essential to conditionally apply them only when needed. Checking for existing headers prevents conflicts and ensures your interceptor works harmoniously with any manually configured requests. The implementation should merge new headers with existing ones rather than replacing them entirely.
Request Logging and Debugging
Logging request details aids debugging and monitoring. A well-structured logging interceptor captures timing, headers, body content, and response status for analysis.
window.fetch = async (...args) => {
const [resource, config = {}] = args;
const startTime = Date.now();
// Use console.group for organized, collapsible output
console.group(`FETCH: ${config.method || 'GET'} ${resource}`);
console.log('Timestamp:', new Date().toISOString());
console.log('Headers:', config.headers);
// Only log body for non-GET requests
if (config.body && config.method !== 'GET') {
console.log('Body:', typeof config.body === 'string'
? JSON.parse(config.body)
: config.body);
}
console.groupEnd();
const response = await originalFetch(resource, config);
const duration = Date.now() - startTime;
// Log response with timing information
console.log(`Response from ${resource}: ${response.status} (${duration}ms)`);
return response;
};
URL Modification and Redirection
Request interceptors can also modify URLs before sending, enabling dynamic API routing based on environment, appending query parameters, or implementing request redirection logic.
window.fetch = async (...args) => {
let [resource, config = {}] = args;
// Environment-based API routing
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.example.com';
// Replace relative URLs with full base URL
if (resource.startsWith('/api/')) {
resource = `${apiBaseUrl}${resource}`;
}
// Append common query parameters
if (resource.includes('?')) {
resource += `&clientVersion=${process.env.NEXT_PUBLIC_APP_VERSION}`;
} else {
resource += `?clientVersion=${process.env.NEXT_PUBLIC_APP_VERSION}`;
}
const response = await originalFetch(resource, config);
return response;
};
1window.fetch = async (...args) => {2 let [resource, config = {}] = args;3 4 // Add authorization header if not already present5 if (!config.headers || !config.headers.Authorization) {6 const token = localStorage.getItem('authToken');7 if (token) {8 config = {9 ...config,10 headers: {11 ...config.headers,12 'Authorization': `Bearer ${token}`13 }14 };15 }16 }17 18 const response = await originalFetch(resource, config);19 return response;20};Response Interceptors
Response interceptors process the server's response before it reaches your application code. This enables data transformation, metadata addition, and consistent error handling.
Transforming Response Data
Modifying response data allows you to normalize API responses, add computed fields, or prepare data for your application's needs before any component processes it.
window.fetch = async (...args) => {
const [resource, config] = args;
const response = await originalFetch(resource, config);
// Clone response before reading body
// The response body can only be read once, so cloning is required
const clonedResponse = response.clone();
try {
// Parse the response data
const data = await clonedResponse.json();
// Transform the response data with metadata
const transformedData = {
...data,
_meta: {
fetchedAt: new Date().toISOString(),
sourceUrl: resource,
status: response.status,
cached: false
}
};
// Create new response with transformed data
return new Response(JSON.stringify(transformedData), {
status: response.status,
statusText: response.statusText,
headers: new Headers(response.headers)
});
} catch (error) {
// Handle non-JSON responses gracefully
return response;
}
};
Important: When modifying response data, you must clone the response before reading its body. The response body can only be read once, and reading it consumes the stream. Cloning creates a copy that can be safely modified while preserving the original for any other consumers.
Handling HTTP Errors Consistently
The Fetch API does not reject promises for HTTP error statuses like 404 or 500. A response interceptor can normalize this behavior by throwing errors for non-ok responses, matching the behavior developers expect from other HTTP libraries.
window.fetch = async (...args) => {
const [resource, config = {}] = args;
const response = await originalFetch(resource, config);
// Check for HTTP error status
if (!response.ok) {
// Try to extract error details from response body
let errorData;
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
try {
errorData = await response.clone().json();
} catch (e) {
errorData = { message: response.statusText };
}
} else {
errorData = { message: response.statusText };
}
// Throw consistent error object
throw new ApiError(
response.status,
errorData.message || `HTTP ${response.status}: ${response.statusText}`,
errorData
);
}
return response;
};
This pattern ensures that any HTTP error status (4xx, 5xx) results in a rejected promise, making error handling consistent across your application.
1window.fetch = async (...args) => {2 const [resource, config] = args;3 const response = await originalFetch(resource, config);4 5 // Clone response before reading body6 const clonedResponse = response.clone();7 8 // Transform the response data9 const data = await clonedResponse.json();10 11 // Modify data before returning12 const transformedData = {13 ...data,14 _meta: {15 fetchedAt: new Date().toISOString(),16 sourceUrl: resource,17 status: response.status18 }19 };20 21 // Create new response with transformed data22 return new Response(JSON.stringify(transformedData), {23 status: response.status,24 statusText: response.statusText,25 headers: response.headers26 });27};Error Handling in Interceptors
Comprehensive error handling in fetch interceptors covers both network-level failures and HTTP error statuses. A well-designed error handling strategy ensures your application gracefully handles all failure scenarios.
Network Errors
Network errors occur when the request cannot complete due to connectivity issues, DNS failures, or server unavailability. The Fetch API throws a TypeError for these scenarios, which you can catch and transform into more informative errors.
window.fetch = async (...args) => {
try {
const response = await originalFetch(...args);
if (!response.ok) {
// Handle HTTP errors
const errorData = await response.json().catch(() => ({}));
throw new ApiError(
response.status,
errorData.message || `HTTP ${response.status}: ${response.statusText}`
);
}
return response;
} catch (error) {
// Detect network failures by error type and message
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new NetworkError(
'Unable to connect to the server. Please check your internet connection.'
);
}
// Re-throw other errors unchanged
throw error;
}
};
Custom Error Classes
Creating custom error classes helps organize different error types and enables type-specific handling in your application.
// ApiError for HTTP-level errors (4xx, 5xx responses)
class ApiError extends Error {
constructor(status, message, responseData = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.responseData = responseData;
this.timestamp = new Date().toISOString();
}
// Helper methods for common error types
static isUnauthorized(status) { return status === 401; }
static isForbidden(status) { return status === 403; }
static isNotFound(status) { return status === 404; }
static isServerError(status) { return status >= 500; }
}
// NetworkError for connectivity issues
class NetworkError extends Error {
constructor(message, originalError = null) {
super(message);
this.name = 'NetworkError';
this.originalError = originalError;
this.timestamp = new Date().toISOString();
}
}
// ValidationError for client-side input issues
class ValidationError extends Error {
constructor(message, fieldErrors = {}) {
super(message);
this.name = 'ValidationError';
this.fieldErrors = fieldErrors;
}
}
Status Code Handling
Different HTTP status codes require different handling strategies. A comprehensive interceptor can route specific status codes to appropriate handlers.
- 401 Unauthorized: Trigger re-authentication flow, redirect to login, or refresh the token automatically
- 403 Forbidden: Log and notify user of insufficient permissions, possibly redirect to access denied page
- 404 Not Found: Handle gracefully with user-friendly messages, possibly use cached data as fallback
- 429 Rate Limiting: Implement automatic backoff with exponential delay, queue requests if needed
- 5xx Server Errors: Implement retry logic with circuit breaker pattern, show graceful degradation to users
Implementing these handlers in a central interceptor ensures consistent behavior across all API calls without repeating the logic in each individual request handler. For applications that require robust API integration, centralized error handling significantly improves maintainability and user experience.
Performance Considerations
Fetch interceptors add overhead to every network request, making performance optimization essential for maintaining application responsiveness.
Minimizing Overhead
Keep interceptor logic as lightweight as possible. Avoid heavy computations, unnecessary object creation, or blocking operations within interceptor callbacks. Use conditional logic to skip processing when interception isn't needed. Profile your interceptor in browser DevTools to identify bottlenecks.
Response Cloning Costs
Cloning responses for modification doubles the memory usage for response bodies. Only clone when necessary, and consider alternative approaches like response streaming for large payloads. For simple metadata addition, consider using response headers instead of body modification.
Conditional Interception
Not every fetch request requires interception. Implementing conditional logic to skip interceptor processing for non-API requests significantly reduces overhead.
window.fetch = async (...args) => {
const [resource, config = {}] = args;
// Skip interception for non-API requests
// This includes static assets, images, and third-party resources
if (!resource.includes('/api/') && !resource.includes('/graphql')) {
return originalFetch(...args);
}
// Apply interception only for API calls
// Add auth headers, logging, error handling, etc.
const token = localStorage.getItem('authToken');
if (token) {
config.headers = {
...config.headers,
'Authorization': `Bearer ${token}`
};
}
const response = await originalFetch(resource, config);
// Only transform responses that are JSON
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json') && !response.ok) {
// Handle errors for API calls only
const errorData = await response.clone().json().catch(() => ({}));
throw new ApiError(response.status, errorData.message || 'Request failed');
}
return response;
};
This pattern ensures that static assets, CDN resources, and third-party API calls bypass the interceptor entirely, reducing overhead for requests that don't benefit from interception logic.
Next.js Integration Patterns
Implementing fetch interception in Next.js applications requires consideration of both client-side and server-side contexts.
Client-Side Interception
For client-side components, create a setup function that initializes the interceptor once, using a flag to prevent multiple initializations. This function should be called in your app's entry point or in a layout component.
// lib/fetchInterceptor.js
let interceptorSetup = false;
export function setupFetchInterceptor() {
// Prevent multiple setup calls
if (interceptorSetup) return;
const { fetch: originalFetch } = window;
window.fetch = async (...args) => {
const [resource, config = {}] = args;
// Add authentication for API calls
if (resource.includes('/api/') && !resource.includes('/public/')) {
const token = localStorage.getItem('authToken');
if (token) {
config.headers = {
...config.headers,
'Authorization': `Bearer ${token}`
};
}
}
const response = await originalFetch(resource, config);
// Handle 401 errors globally
if (response.status === 401) {
// Trigger logout or token refresh
localStorage.removeItem('authToken');
window.location.href = '/login';
}
return response;
};
interceptorSetup = true;
}
// In your _app.js or root layout
if (typeof window !== 'undefined') {
setupFetchInterceptor();
}
Server-Side Considerations
Server components in Next.js should not use fetch interception, as they run in a different context without access to window. For server-side request modification, use Next.js Middleware instead, which runs before requests reach your server components or API routes. API routes can implement their own interception logic if needed, or use middleware for consistent behavior.
Using with React Query or SWR
Data fetching libraries like React Query and SWR have their own caching and request handling mechanisms. Fetch interceptors work alongside these libraries, adding authentication headers and handling errors globally without interfering with caching logic. However, be aware that custom fetch functions passed to these libraries may need additional configuration to work with your interceptor setup.
When using fetch interception with React Query, the interceptor runs before React Query's own request handling, ensuring that auth headers are always present regardless of how the query is configured. SWR similarly benefits from interceptors, with the added advantage that cached responses can still be returned during network failures.
For optimal integration, initialize your interceptor before rendering any React Query or SWR providers, ensuring all data fetching operations benefit from the centralized interception logic.
Best Practices
Following established best practices ensures your fetch interception implementation is maintainable, debuggable, and performant.
Code Organization
Extract interceptor logic into a dedicated module with clear separation of concerns. Use configuration objects to control conditional behavior. Document all intercepted behavior thoroughly, as developers unfamiliar with your codebase may not expect requests to be modified.
// lib/api/fetchConfig.js
export const fetchConfig = {
// URLs to intercept
apiPaths: ['/api/', '/graphql'],
// URLs to skip
skipPaths: ['/public/', '/static/', '.jpg', '.png'],
// Custom headers to add
customHeaders: {
'X-Client-Version': process.env.NEXT_PUBLIC_APP_VERSION
},
// Error handling options
errors: {
handle401: true,
handle403: false,
handle429: true
}
};
// lib/api/interceptor.js
import { fetchConfig } from './fetchConfig';
export function createFetchInterceptor(originalFetch) {
return async function interceptedFetch(url, options = {}) {
// Implementation using fetchConfig
// ...
};
}
Debugging Interceptor Issues
When debugging issues in intercepted requests, use structured logging with console.group for organized output. Add unique identifiers to tracked requests to correlate logs with network activity. Browser DevTools breakpoints can be set on your custom fetch function for step-through debugging. Consider adding a debug mode that can be toggled without code changes.
Avoiding Common Pitfalls
Several anti-patterns can cause issues with fetch interception:
DO:
- Always preserve the original fetch function before overriding
- Return the response object (or a new Response) from your interceptor
- Clone responses before reading their body
- Use conditional logic to skip unnecessary interception
- Handle both network errors and HTTP error statuses
- Test your interceptor with various request types and responses
DON'T:
- Modify request body after sending the request
- Use blocking operations that delay request processing
- Overwrite original response data without cloning first
- Forget to handle edge cases like aborted requests
- Apply interception to non-API requests unnecessarily
- Change the shape of the response object unexpectedly
Following these guidelines ensures your interceptor remains a helpful abstraction rather than a source of unexpected behavior.
Advanced Patterns
Beyond basic interception, several advanced patterns enable sophisticated request and response handling.
Retry Logic
Automatic retry with exponential backoff improves reliability for transient failures. Implementing retry logic in an interceptor centralizes this behavior without modifying individual API calls.
window.fetch = async (url, options = {}, retries = 3) => {
// Store original fetch before patching
const { fetch: originalFetch } = window;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await originalFetch(url, options);
// Don't retry for client errors (4xx)
if (!response.ok && response.status < 500 && response.status !== 429) {
return response;
}
// Retry for server errors or rate limiting
if (!response.ok && attempt < retries) {
// Exponential backoff: 100ms, 200ms, 400ms...
const delay = Math.pow(2, attempt) * 100;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
return response;
} catch (error) {
// Network failure - retry if attempts remain
if (attempt === retries) {
throw error;
}
// Wait before retrying
const delay = Math.pow(2, attempt) * 100;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
};
Request/Response Caching
Implementing caching in interceptors can significantly improve performance for frequently accessed resources. A simple in-memory cache stores responses keyed by URL, respecting cache-control headers and implementing proper invalidation.
const responseCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
window.fetch = async (...args) => {
const [resource] = args;
// Check cache first
const cached = responseCache.get(resource);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
console.log('Returning cached response for:', resource);
return new Response(cached.body, {
status: cached.status,
statusText: cached.statusText,
headers: cached.headers
});
}
const response = await originalFetch(...args);
// Cache successful GET requests
const method = args[1]?.method || 'GET';
if (method === 'GET' && response.ok) {
const body = await response.clone().text();
responseCache.set(resource, {
body,
status: response.status,
statusText: response.statusText,
headers: response.headers,
timestamp: Date.now()
});
}
return response;
};
For more sophisticated caching, consider implementing stale-while-revalidate patterns, cache invalidation webhooks, or integration with service workers for offline support.
Conclusion
Fetch interception provides a powerful mechanism for implementing cross-cutting concerns in modern web applications. By understanding the monkey patching technique, request and response interceptor patterns, and performance considerations, you can build robust, maintainable HTTP layers that serve your application's needs.
Whether you're working with authentication, logging, error handling, or data transformation, fetch interception gives you the flexibility to implement these features consistently across your entire application. The key is to balance flexibility with maintainability, keeping interceptors focused and well-documented.
Start by implementing basic interception for your most common use case, then expand functionality as your application's needs evolve. With proper testing and monitoring, fetch interceptors become an invaluable tool in your web development toolkit.
For applications built with modern frameworks like Next.js, integrating fetch interception with your existing architecture ensures that all data fetching benefits from centralized handling without sacrificing performance or compatibility with other libraries like React Query or SWR. Our web development team can help you implement these patterns and build scalable, maintainable applications that handle API communications reliably.
Frequently Asked Questions
Is monkey patching fetch safe for production?
Yes, monkey patching fetch is widely used in production applications. However, ensure you preserve all original fetch functionality and thoroughly test your implementation across different browsers and network conditions.
How does fetch interception affect performance?
Performance impact depends on interceptor complexity. Simple header additions have minimal impact, while heavy processing can add noticeable latency. Use conditional interception and keep logic lightweight to minimize overhead.
Can I use fetch interception with service workers?
Service workers intercept fetch requests at the network level, which can conflict with client-side interception. Consider implementing interception logic in the service worker itself for advanced caching and offline support.
How do I test fetch interceptors?
Test interceptors by mocking fetch, verifying request modifications, and checking response transformations. Use tools like Jest with fetch mocks to isolate interceptor behavior from network requests.
Sources
- MDN Web Docs - Fetch API - Official documentation for the Fetch API
- NamasteDev - Fetch API Deep Dive: Requests, Responses, and Interceptors - Implementation patterns and interceptor code
- Vishal Garg - Intercepting JavaScript Fetch API - Advanced interceptor patterns and error handling