node-fetch Fetch API: window.fetch for Node
Welcome to TopperBlog! 👋
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
🎯 What I Write About:
• AI/ML Engineering & LLMs
• Web3 & Blockchain Development
• System Design & Architecture
• Interview Preparation (FAANG)
• Freelancing & Remote Work
• Modern Tech Stacks (Next.js, React, Rust, TypeScript)
• Performance Optimization & Best Practices
💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
🌐 Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
Bringing window.fetch to Node.js: The node-fetch Story
I still remember the day I migrated a React app to server-side rendering. Everything worked beautifully in the browser, but the moment I fired up the Node.js server, it crashed. The culprit? A simple fetch() call that worked perfectly client-side but didn't exist in Node.js.
For years, JavaScript developers lived in two worlds. Browser code used the elegant Fetch API while Node.js developers juggled http.request(), callbacks, and third-party libraries like request or axios. You'd write one HTTP client for the frontend and a completely different one for the backend. The context switching was exhausting, and sharing code between environments felt impossible.
That's exactly why node-fetch exists. It brings the browser's Fetch API to Node.js, letting you write the same HTTP code everywhere. No more learning different APIs. No more maintaining separate implementations. Just clean, consistent fetch calls across your entire JavaScript stack.
Table of Contents
- What is node-fetch Fetch API?
- Quick Start
- 5 Essential Patterns
- Comparison Table
- 3 Common Mistakes
- FAQ
- Conclusion
What is node-fetch Fetch API?
node-fetch is a lightweight module that implements the browser's window.fetch API for Node.js environments. Created by David Frank, it became the de facto standard for bringing modern HTTP request capabilities to server-side JavaScript before Node.js v18 finally added native fetch support.
The library mirrors the WHATWG Fetch specification almost perfectly, meaning code you write with node-fetch works identically in browsers. It returns Promises, supports streaming responses, handles various body types (JSON, text, FormData, Blob), and respects standard HTTP semantics.
Under the hood, node-fetch wraps Node.js's native http and https modules but exposes them through the familiar fetch interface. This abstraction eliminates the complexity of dealing with streams, chunks, and event emitters directly. You get a clean, Promise-based API that feels natural to modern JavaScript developers.
The package is remarkably small—around 500 lines of code—yet powerful enough for most HTTP use cases. It supports redirects, timeout handling, request cancellation via AbortController, and custom headers. Whether you're building REST API clients, scraping websites, or implementing server-side data fetching, node-fetch provides the tools you need without the bloat of heavier HTTP libraries.
Quick Start
Getting started with node-fetch takes less than five minutes. First, install it via npm:
npm install node-fetch
For Node.js versions below 18, you'll want version 2.x which uses CommonJS. For modern Node.js with ESM support, use version 3.x:
// ESM (node-fetch v3)
import fetch from 'node-fetch';
// CommonJS (node-fetch v2)
const fetch = require('node-fetch');
// Basic GET request
const response = await fetch('https://api.github.com/users/github');
const data = await response.json();
console.log(data.name); // "GitHub"
The API is intentionally identical to browser fetch. You call fetch() with a URL, get back a Promise that resolves to a Response object, then extract the body using methods like .json(), .text(), or .blob().
Here's a POST request example:
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'My Post',
body: 'Content here',
userId: 1
})
});
const newPost = await response.json();
console.log(newPost.id); // 101
The second parameter accepts an options object where you specify the HTTP method, headers, body, and other request configurations. This pattern stays consistent across all HTTP verbs—PUT, PATCH, DELETE—making the API predictable and easy to remember.
5 Essential Patterns
1. Error Handling with Status Checks
async function fetchWithErrorHandling(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
}
try {
const data = await fetchWithErrorHandling('https://api.example.com/data');
console.log(data);
} catch (error) {
console.error('Fetch failed:', error.message);
}
Unlike axios, fetch doesn't automatically throw errors for non-2xx status codes. The Promise only rejects on network failures. You must manually check response.ok or response.status to handle HTTP errors properly. This explicit approach gives you fine-grained control over error handling. You can differentiate between 404s, 500s, and network timeouts, responding appropriately to each scenario. Always wrap your fetch calls in try-catch blocks and validate the response status before parsing the body.
2. Request Timeouts with AbortController
async function fetchWithTimeout(url, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
signal: controller.signal
});
clearTimeout(timeoutId);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('Request timeout');
}
throw error;
}
}
Timeouts prevent your application from hanging indefinitely on slow or unresponsive servers. The AbortController API provides a standard way to cancel fetch requests. Create a controller, pass its signal to fetch, and call abort() when needed. This pattern is crucial for production applications where you need predictable response times. Set reasonable timeouts based on your use case—API calls might need 5 seconds, while file uploads could require minutes.
3. 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 (i === maxRetries - 1) throw new Error('Max retries reached');
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
} catch (error) {
if (i === maxRetries - 1) throw error;
}
}
}
Network requests fail. Servers go down, connections drop, and rate limits hit. Implementing retry logic with exponential backoff makes your application resilient. This pattern waits progressively longer between retries (1s, 2s, 4s), reducing server load and increasing success chances. Only retry idempotent operations (GET, PUT, DELETE) and consider adding jitter to prevent thundering herd problems when multiple clients retry simultaneously.
4. Streaming Large Responses
import { createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
async function downloadFile(url, destination) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download: ${response.status}`);
}
await pipeline(
response.body,
createWriteStream(destination)
);
console.log('Download complete');
}
await downloadFile('https://example.com/large-file.zip', './file.zip');
Don't load massive files into memory. The Response body is a Node.js stream, letting you process data incrementally. This pattern is essential for downloading large files, processing CSV data, or handling video streams. Using pipeline() ensures proper error handling and cleanup. The stream automatically handles backpressure, preventing memory overflow when the source produces data faster than the destination can consume it.
5. Parallel Requests with Promise.all
async function fetchMultipleUsers(userIds) {
const requests = userIds.map(id =>
fetch(`https://api.github.com/users/${id}`)
.then(res => res.json())
);
const users = await Promise.all(requests);
return users;
}
const users = await fetchMultipleUsers(['torvalds', 'gaearon', 'tj']);
console.log(users.map(u => u.name));
When you need data from multiple endpoints, parallel requests dramatically reduce total wait time. Instead of fetching sequentially (10 seconds for 10 requests), fetch simultaneously (1-2 seconds total). Promise.all() waits for all requests to complete, failing fast if any request fails. For more fault-tolerant scenarios, use Promise.allSettled() to get results even when some requests fail. Be mindful of rate limits and server capacity when parallelizing requests.
Comparison Table
| Feature | node-fetch | axios | native fetch (Node 18+) |
| Bundle Size | 2.5KB | 13KB | 0KB (built-in) |
| Browser Compatible | ✅ Yes | ✅ Yes | ✅ Yes |
| Auto JSON Transform | ❌ Manual | ✅ Automatic | ❌ Manual |
| Interceptors | ❌ No | ✅ Yes | ❌ No |
| Timeout Support | AbortController | Built-in option | AbortController |
| Error Handling | Manual status check | Auto-throw on error | Manual status check |
| Streaming | ✅ Native streams | ⚠️ Limited | ✅ Native streams |
| TypeScript | ✅ Included | ✅ Included | ✅ Built-in |
3 Common Mistakes
Forgetting to Check response.ok
The biggest mistake developers make is assuming fetch throws errors for bad HTTP status codes. It doesn't. A 404 or 500 response resolves successfully—you must check response.ok or response.status manually. I've debugged countless issues where applications silently failed because they parsed error HTML as JSON. Always validate the response status before processing the body. Create a wrapper function that checks response.ok and throws appropriately, then use that wrapper consistently across your codebase. This simple habit prevents mysterious parsing errors and makes debugging infinitely easier.
Not Handling JSON Parsing Errors
Calling response.json() can throw if the response body isn't valid JSON. This happens more often than you'd think—servers return HTML error pages, empty responses, or malformed data. Always wrap JSON parsing in try-catch blocks or check the Content-Type header first. I once spent hours debugging a production issue where a CDN returned a maintenance page instead of API data. The application crashed trying to parse HTML as JSON. Defensive parsing with proper error messages saves you from these headaches and provides better user experiences.
Ignoring Memory Leaks with Streams
When working with response streams, failing to properly consume or destroy them causes memory leaks. If you check response.ok and throw an error without reading the body, the stream remains open, holding resources. Always consume the response body—even if you're discarding it—by calling response.text(), response.json(), or manually draining the stream. For large responses you're rejecting, consider reading and discarding the body in chunks. This pattern is especially critical in long-running servers where small leaks compound into major memory issues over time.
FAQ
Q: Should I use node-fetch or native fetch in Node.js 18+?
For new projects on Node.js 18 or later, use native fetch—it's built-in, requires no dependencies, and receives ongoing maintenance from the Node.js team. However, node-fetch remains valuable for libraries supporting older Node versions or when you need consistent behavior across Node versions. The APIs are nearly identical, so migration is straightforward. If you're maintaining existing code with node-fetch, there's no urgent need to migrate unless you're dropping support for older Node versions. The performance difference is negligible for most applications.
Q: How do I send form data with node-fetch?
Use the FormData class from the formdata-node package (node-fetch v2) or the built-in FormData (Node 18+). Create a FormData instance, append fields, and pass it as the body. Node-fetch automatically sets the correct Content-Type header with boundary. For file uploads, append file streams or buffers. Example: const form = new FormData(); form.append('file', fileStream); await fetch(url, { method: 'POST', body: form }). The multipart encoding happens automatically, making file uploads as simple as browser-based forms.
Q: Can I use node-fetch with TypeScript?
Absolutely. Node-fetch v3 includes TypeScript definitions out of the box. Import it normally and enjoy full type safety for requests and responses. The types match the WHATWG Fetch specification, so if you're familiar with browser fetch types, you're already set. For v2, install @types/node-fetch separately. TypeScript catches common mistakes like incorrect header names or body types at compile time. Generic types let you specify expected response shapes: const data = await response.json<User>(). This type safety is invaluable in large codebases.
Q: How do I handle cookies with node-fetch?
Node-fetch doesn't automatically handle cookies like browsers do—you must manage them manually. For simple cases, extract cookies from response headers and include them in subsequent requests. For complex scenarios, use the tough-cookie library with a cookie jar. Create a jar, extract cookies from Set-Cookie headers, store them, and serialize them back into Cookie headers for future requests. This manual approach gives you complete control over cookie behavior, which is essential for server-side applications that need to maintain sessions across multiple requests.
Q: Why does my POST request have no body on the server?
This usually happens when you forget to set the Content-Type header or stringify the body. Fetch doesn't automatically serialize objects—you must call JSON.stringify() on your data and set Content-Type: application/json. Another common issue is sending the body with GET requests, which some servers ignore. Verify your request method matches your intent. Use browser DevTools or tools like tcpdump to inspect the actual HTTP request being sent. Double-check that your server is configured to parse the content type you're sending.
Conclusion
The node-fetch library solved a fundamental problem in JavaScript development: the disconnect between browser and server HTTP APIs. By bringing the Fetch API to Node.js, it enabled code sharing, reduced cognitive load, and made server-side HTTP requests feel natural to frontend developers.
While Node.js 18+ now includes native fetch support, node-fetch remains relevant for backward compatibility and as a testament to how community-driven solutions can influence platform development. The patterns you learn with node-fetch—error handling, streaming, timeouts—apply universally across HTTP clients.
Whether you're building REST API clients, implementing server-side rendering, or creating microservices, understanding fetch is essential. Start with the basics, implement proper error handling, and gradually adopt advanced patterns like retries and streaming. The consistency between browser and Node.js environments means skills transfer seamlessly, making you more productive across the entire JavaScript ecosystem.
The future of JavaScript is unified, and fetch is a big part of that story.