Use node-fetch when you want browser-style HTTP requests in Node.js without dragging in a heavy client. It gives you the familiar fetch() pattern for calling APIs, sending JSON, adding headers, and handling messy failures. The package describes itself as a light-weight module that brings the Fetch API to Node.js [source: npm].

TLDR: node-fetch is best for simple API work: GET data, POST JSON, pass auth headers, and control failures with timeouts. For example, if a dashboard loads 5 API endpoints and each request takes 300 ms, running them in parallel can cut wait time from about 1.5 seconds to roughly 300 ms. That is the kind of boring win users actually notice. The catch is that error handling is not automatic, so you need to check response status yourself.

1. Fetch JSON with a simple GET request

The most common use is plain old data fetching. You call an API, wait for the response, then parse it as JSON. If you have used fetch() in the browser, this will feel familiar.

import fetch from 'node-fetch';

async function getUser() {
  const response = await fetch('https://api.example.com/users/42');

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  const user = await response.json();
  console.log(user);
}

getUser();

This pattern is small, clear, and easy to test. The key line is response.ok. It returns true for successful status codes like 200 and 201. It returns false for errors like 404 or 500.

Honestly, it feels like fetch should throw on a bad HTTP status, but it does not. A failed server response is still a valid response. So check it every time.

2. Send JSON with POST requests

Use POST when you want to create something or send data to an API. This is common for login forms, checkout pages, contact forms, analytics events, and backend-to-backend calls.

import fetch from 'node-fetch';

async function createTodo() {
  const response = await fetch('https://api.example.com/todos', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      title: 'Write API docs',
      done: false
    })
  });

  if (!response.ok) {
    throw new Error(`Could not create todo: ${response.status}`);
  }

  const result = await response.json();
  console.log(result);
}

createTodo();

The important parts are method, headers, and body. The body must be a string when sending JSON, so use JSON.stringify(). The Content-Type header tells the server what you are sending.

This is also where bugs creep in. Miss one header, and the server may reject the request or read the body as plain text. Expect to waste time on that at least once.

3. Add headers for authentication and API keys

Most real APIs are not open to the public. They expect a token, key, cookie, or custom header. node-fetch lets you pass those headers in the request options.

import fetch from 'node-fetch';

async function getOrders(token) {
  const response = await fetch('https://api.example.com/orders', {
    headers: {
      'Authorization': `Bearer ${token}`,
      'Accept': 'application/json'
    }
  });

  if (!response.ok) {
    throw new Error(`Auth or request error: ${response.status}`);
  }

  return response.json();
}

Keep secrets out of your code. Store tokens in environment variables or a secret manager. A simple pattern looks like this:

const token = process.env.API_TOKEN;

That may seem basic, but it matters. Hard-coded tokens get copied into Git repos, shared in screenshots, and left in old test files. Then someone has to rotate keys on a Friday afternoon. Nobody wants that.

4. Handle timeouts, retries, and parallel requests

APIs fail. Networks hiccup. Some services take four seconds to answer a request that should take 200 ms. Your code should not hang forever.

You can use AbortController to stop a slow request:

import fetch from 'node-fetch';

async function fetchWithTimeout(url, ms = 3000) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), ms);

  try {
    const response = await fetch(url, {
      signal: controller.signal
    });

    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }

    return await response.json();
  } finally {
    clearTimeout(timeout);
  }
}

This keeps your app from waiting forever. It also gives you a clean place to add logging or retry logic.

For multiple requests, use Promise.all(). This is useful when your API calls do not depend on each other.

import fetch from 'node-fetch';

async function loadDashboard() {
  const urls = [
    'https://api.example.com/sales',
    'https://api.example.com/users',
    'https://api.example.com/alerts'
  ];

  const responses = await Promise.all(
    urls.map(url => fetch(url))
  );

  const data = await Promise.all(
    responses.map(response => response.json())
  );

  return data;
}

Parallel calls can make the app feel much quicker. Just do not blast an API with hundreds of requests at once. Many services limit traffic, and they will return 429 Too Many Requests when you push too hard.

Quick setup tips

  • Use ESM imports: Modern node-fetch uses import, not old require(), unless you use an older version.
  • Always check response.ok: Fetch does not throw for bad HTTP status codes.
  • Parse based on content: Use response.json() for JSON and response.text() for plain text.
  • Log useful errors: Include status codes and URLs, but never log private tokens.

node-fetch shines when you want clean HTTP calls without a large wrapper. Start with GET and POST, add headers for real APIs, then build in timeouts before production traffic hits. Small habits like status checks and safe token handling save hours later.