Intercepting JavaScript Fetch API Requests and Responses

Master the techniques for adding authentication headers, logging requests, transforming responses, and handling errors consistently across your web applications.

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.

What You'll Learn

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.

Basic Fetch Interceptor Pattern
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;
};
Authentication Header Interceptor
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.

Response Data Transformation
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.

Need Help Building Robust Web Applications?

Our team of experienced developers can help you implement advanced patterns like fetch interception and build performant, scalable web applications.

Sources

  1. MDN Web Docs - Fetch API - Official documentation for the Fetch API
  2. NamasteDev - Fetch API Deep Dive: Requests, Responses, and Interceptors - Implementation patterns and interceptor code
  3. Vishal Garg - Intercepting JavaScript Fetch API - Advanced interceptor patterns and error handling