The Fetch API Explained for Modern JavaScript Developers

Stackademic

Learn how the Fetch API works in browsers and Node.js, with practical examples for GET, POST, error handling, and when to reach for axios instead.

The Fetch API is the standard way to make HTTP requests in modern JavaScript. If you have worked with XMLHttpRequest or jQuery's $.ajax, Fetch replaces most of that ceremony with a Promise-based interface that works in browsers and, with small differences, in Node.js 18 and later.

This guide walks through what Fetch actually does, where it shines, and the patterns that keep production code readable.

What the Fetch API does

At its core, fetch() sends an HTTP request and returns a Promise that resolves to a Response object. You choose a URL, optionally pass a config object for method, headers, and body, then read the response.

const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);

Unlike older APIs, Fetch separates the network request from reading the body. The Promise resolves as soon as response headers arrive, even if the body is still streaming. That design enables streaming and large downloads without blocking on the full payload.

A minimal GET request

The simplest call needs only a URL. Fetch defaults to GET and omits a body.

async function loadPosts() {
  const res = await fetch('/api/posts');

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

  return res.json();
}

Two details trip up beginners:

  1. Fetch does not throw on HTTP error status codes. A 404 or 500 still resolves the Promise. You must check response.ok (true for status 200–299) or inspect response.status yourself.
  2. You can only read the body once. Calling res.json() consumes the stream. If you need the raw text and parsed JSON, call res.text() first and parse manually.

POST requests with JSON

Sending JSON is the most common pattern in REST APIs.

async function createUser(name, email) {
  const res = await fetch('/api/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: JSON.stringify({ name, email }),
  });

  if (!res.ok) {
    const error = await res.json().catch(() => ({}));
    throw new Error(error.message ?? `HTTP ${res.status}`);
  }

  return res.json();
}

The headers object is a plain record. Fetch sets some headers automatically (for example, CORS-related headers in browsers), but you are responsible for Content-Type when sending JSON.

Working with FormData and file uploads

For HTML forms and file uploads, use FormData instead of JSON.

const form = document.querySelector('#upload-form');
const data = new FormData(form);

const res = await fetch('/api/upload', {
  method: 'POST',
  body: data,
});

Do not set Content-Type manually when using FormData. The browser adds the correct multipart/form-data boundary.

Authentication patterns

Fetch has no built-in auth helpers. Common approaches:

  • Bearer tokens: add Authorization: Bearer <token> to headers.
  • Cookies: rely on same-origin cookie sending (default in browsers) or credentials: 'include' for cross-origin requests that need cookies.
  • API keys: pass via header or query string depending on the API contract.
const res = await fetch('/api/protected', {
  headers: {
    Authorization: `Bearer ${token}`,
  },
  credentials: 'include',
});

Never embed secrets in frontend code. Tokens should come from your auth flow, not hard-coded strings.

Error handling that scales

Production code benefits from a thin wrapper:

async function api(path, options = {}) {
  const res = await fetch(path, {
    ...options,
    headers: {
      Accept: 'application/json',
      ...options.headers,
    },
  });

  const text = await res.text();
  const data = text ? JSON.parse(text) : null;

  if (!res.ok) {
    const message = data?.message ?? res.statusText;
    throw new Error(`${res.status}: ${message}`);
  }

  return data;
}

This pattern gives you consistent parsing, readable errors, and a single place to add logging or retry logic later.

Timeouts and cancellation

Fetch has no timeout option. Use AbortController:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);

try {
  const res = await fetch('/api/slow', { signal: controller.signal });
  // handle response
} catch (err) {
  if (err.name === 'AbortError') {
    console.error('Request timed out');
  }
} finally {
  clearTimeout(timeout);
}

AbortController also lets users cancel in-flight requests when navigating away or typing in a search box.

Fetch in Node.js

Node 18+ ships a global fetch built on undici. Behavior is close to browser Fetch but watch for differences:

  • Relative URLs require a base (fetch('http://localhost:3000/api'), not fetch('/api') unless you pass a Request with base).
  • Cookie handling differs; use a dedicated HTTP client or manual cookie jars when you need browser-like session behavior.
  • Some TLS and proxy edge cases surface differently than in browsers.

For server-side apps, Fetch is fine for simple outbound calls. Heavy connection pooling or legacy protocols may still warrant undici directly or another client.

Fetch vs axios and other libraries

ConcernFetchaxios
Bundle sizeBuilt into modern runtimesExtra dependency
JSON transformsManualAutomatic
HTTP errorsDoes not throwThrows on 4xx/5xx
InterceptorsRoll your ownBuilt-in
Upload progressLimitedSupported

Choose Fetch when you want zero dependencies and your needs are straightforward. Reach for axios or ky when interceptors, automatic JSON, or upload progress save meaningful time.

Common pitfalls

CORS errors in the browser mean the server did not allow your origin. Fixing them requires server configuration, not a different client library.

Mixing async/await with forgotten await on fetch gives you a Promise object instead of a Response. Lint rules and TypeScript catch this quickly.

Parsing JSON on empty responses (like 204 No Content) throws. Guard with res.status !== 204 or try/catch around JSON.parse.

When Fetch is the right default

Fetch fits most frontend and full-stack JavaScript projects: loading data in React components, calling your own API routes, and scripting small automation in Node. Learn its quirks once, wrap them in a helper, and you have a portable skill across browsers, Deno, and Node.

FAQ

Does Fetch work in older browsers? Modern evergreen browsers support it. For legacy targets, polyfills exist, though many teams no longer support IE11.

Can I use Fetch with GraphQL? Yes. POST a JSON body with { query, variables } the same way you would any JSON API.

Is Fetch faster than axios? Performance differences are usually negligible compared to network latency. Pick based on ergonomics and dependencies, not micro-benchmarks.