Native Fetch API in Node.js: Complete Guide to HTTP Requests
Node.js 18+ includes the global fetch API, eliminating the need for external HTTP libraries. This guide covers making requests, handling errors, streaming responses, and advanced patterns like interceptors and retries.

For years, Node.js developers relied on axios, node-fetch, or request for HTTP requests. Since Node.js 18, the global fetch API — identical to the browser's — is available natively. In 2026, it's fully stable, performant, and the recommended approach for most HTTP calls. This guide covers everything from basic GET requests to production patterns.
Why Native Fetch Beats Third-Party Libraries
The native fetch implementation uses the same underlying Undici HTTP stack that powers Node.js core, offering better performance than node-fetch and comparable features to axios without the dependency overhead. Key benefits: no installation, automatic Promise-based API, streaming support, and integration with AbortController.
Basic GET and POST Requests
// GET request with JSON response
const response = await fetch('https://api.example.com/users/123');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
// POST with JSON body
const newUser = await fetch(‘https://api.example.com/users’, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify({ name: ‘Alice’, email: ‘alice@example.com’ })
});
// Form data upload
const formData = new FormData();
formData.append(‘file’, fileBuffer, ‘image.jpg’);
await fetch(‘/upload’, { method: ‘POST’, body: formData });
Advanced: Streaming Large Responses
For large JSON or text responses, stream to avoid memory issues:
const response = await fetch('https://api.example.com/large-dataset');
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
processChunk(chunk); // Process incrementally
}
Error Handling Patterns
Fetch only rejects on network failures, not HTTP error statuses:
async function robustFetch(url, options = {}) {
try {
const response = await fetch(url, {
...options,
signal: AbortSignal.timeout(5000) // 5 second timeout
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`HTTP ${response.status}: ${errorBody}`);
}
return response;
} catch (err) {
if (err.name === ‘AbortError’) {
console.error(‘Request timeout’);
} else if (err.code === ‘ECONNREFUSED’) {
console.error(‘Service unavailable’);
}
throw err;
}
}
Retry Logic with Exponential Backoff
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
if (response.ok) return response;
if (response.status >= 500 && i < maxRetries - 1) {
await sleep(1000 * Math.pow(2, i)); // 1s, 2s, 4s
continue;
}
return response;
} catch (err) {
if (i === maxRetries - 1) throw err;
await sleep(1000 * Math.pow(2, i));
}
}
}
Interceptors (Custom Middleware)
Unlike axios, fetch doesn't have built-in interceptors. Here's a pattern:
class HttpClient {
constructor(baseURL) {
this.baseURL = baseURL;
this.requestInterceptors = [];
this.responseInterceptors = [];
}
addRequestInterceptor(fn) {
this.requestInterceptors.push(fn);
}
addResponseInterceptor(fn) {
this.responseInterceptors.push(fn);
}
async fetch(path, options = {}) {
let opts = { …options };
for (const interceptor of this.requestInterceptors) {
opts = await interceptor(opts);
}
let response = await fetch(`${this.baseURL}${path}`, opts);
for (const interceptor of this.responseInterceptors) {
response = await interceptor(response);
}
return response;
}
}
// Usage
const client = new HttpClient(‘https://api.example.com’);
client.addRequestInterceptor((opts) => {
opts.headers = { …opts.headers, ‘X-API-Key’: process.env.API_KEY };
return opts;
});
client.addResponseInterceptor(async (res) => {
if (res.status === 401) {
await refreshToken();
return client.fetch(res.url, res); // retry
}
return res;
});
Production Considerations
Connection pooling: Native fetch uses Undici's connection pool (default max 256 connections per origin). Tune via globalThis[Symbol.for('undici.globalDispatcher.dispatcher')].
DNS caching: Undici caches DNS for 60 seconds. For rapidly changing endpoints, disable: new Agent({ connections: 100, connect: { lookup: dns.lookup } }).
Memory leaks: Always consume response bodies (even if you don't need them) by calling res.text(), res.json(), or res.arrayBuffer(). Unconsumed bodies leak memory.
Common Mistakes
- Not checking response.ok: Fetch resolves on 404, 500. Always check
if (!response.ok). - Forgetting to set Content-Type: For JSON POST, explicitly set
Content-Type: application/json. - No timeout configuration: Use
AbortSignal.timeout(ms)to avoid hanging requests. - Swallowing errors in streams: Always wrap stream reads in try/catch.
Comparison with Axios in 2026
Axios still has advantages: automatic JSON parsing, interceptors out-of-the-box, request cancellation, and broader browser support. However, native fetch is now fast enough that axios is mostly unnecessary. For new projects, use fetch. For complex needs, consider a tiny wrapper like ofetch.
Conclusion
Native fetch is ready for production. Start migrating from axios gradually. Implement retry logic, timeouts, and error handling as shown. The zero-dependency, standards-based approach wins for most applications. Use fetch for all new HTTP requests in Node.js 22+.
Comments
Join the conversation — sign in to leave a comment.