Skip to content
C Codeloom
JavaScript

Using the Fetch API in JavaScript

A practical guide to the Fetch API: GET and POST requests, JSON, headers, error handling, AbortController, and the 4xx/5xx rejection gotcha that bites everyone.

·7 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • How to make GET and POST requests with fetch
  • Setting headers and sending JSON bodies
  • Why fetch does not reject on 4xx and 5xx responses
  • Robust error handling patterns for real apps
  • Cancelling requests with AbortController

Prerequisites

fetch is the modern way to make HTTP requests from JavaScript. It is built into every current browser and into Node since version 18. The API is small enough to memorize, but it has one famous quirk that catches almost every developer at least once. This article walks through the parts you actually use and the patterns that hold up in production.

The shape of a fetch call

fetch(url, options) returns a promise that resolves to a Response object. To get the body, you call one of the Response methods like json(), text(), or blob(), each of which returns another promise.

const res = await fetch("/api/users");
const users = await res.json();
console.log(users);

Two awaits, two promises. The first resolves when the headers come back. The second resolves when the body is fully read and parsed. This split matters when you stream large responses or want to check the status before reading the body.

A basic GET

For a simple GET request, you do not need an options object at all.

async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

res.ok is true when the status code is in the 200-299 range. Always check it. We will come back to why in a minute.

Sending a POST with JSON

To send a body, set method, headers, and body in the options.

async function createUser(user) {
  const res = await fetch("/api/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(user),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

A few details worth noting:

  • The Content-Type header is not set automatically for JSON. You have to set it.
  • body is a string, not an object. JSON.stringify is required.
  • The server’s response is also JSON only if you call res.json(). The server’s Content-Type does not change what your code does.

Other common body types include FormData (for file uploads), URLSearchParams (for form-encoded data), and raw Blob or ArrayBuffer.

Headers, query strings, and credentials

For query parameters, use URLSearchParams instead of hand-concatenating strings. It handles encoding correctly.

const params = new URLSearchParams({ q: "ada lovelace", page: "2" });
const res = await fetch(`/api/search?${params}`);

For headers, you can pass a plain object or a Headers instance. A Headers instance lets you append multiple values for the same name, which a plain object cannot.

const headers = new Headers();
headers.set("Accept", "application/json");
headers.append("X-Trace", "abc123");

await fetch("/api/data", { headers });

By default, fetch does not send cookies on cross-origin requests. If your API uses session cookies on a different origin, set credentials: "include". For same-origin requests, the default behavior already sends cookies.

await fetch("https://api.example.com/me", {
  credentials: "include",
});

The famous 4xx/5xx gotcha

Here is the thing that catches everyone. fetch only rejects its promise on network failures. A 404 or a 500 is a successful HTTP exchange as far as fetch is concerned. The promise resolves, with res.ok set to false.

try {
  const res = await fetch("/api/this-does-not-exist");
  const data = await res.json(); // runs even on 404
} catch (err) {
  // Only fires on network errors or invalid JSON
}

This is a deliberate design choice. fetch is a thin transport. It does not decide what counts as an error in your domain. You decide. The minimum hygiene is checking res.ok and throwing yourself.

async function http(url, options) {
  const res = await fetch(url, options);
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
  }
  return res.json();
}

Wrap fetch in a helper like this once and use it everywhere. Your call sites become a single line and your error handling becomes consistent. This is the kind of small abstraction that pays compounding dividends, similar to the small helpers built on JavaScript functions.

Error handling that actually works

Real apps need to distinguish three failure modes: the network failed, the server returned an error response, and the body could not be parsed. They deserve different handling.

async function http(url, options) {
  let res;
  try {
    res = await fetch(url, options);
  } catch (err) {
    throw new Error(`Network error: ${err.message}`);
  }

  if (!res.ok) {
    const body = await res.text().catch(() => "");
    throw new Error(`HTTP ${res.status}: ${body}`);
  }

  try {
    return await res.json();
  } catch {
    throw new Error("Invalid JSON in response");
  }
}

This is more code, but it gives you three actionable errors instead of one ambiguous one. Combine this with what you already know about async/await and your try/catch blocks at the call site become tight and meaningful.

Cancelling requests with AbortController

Long-running fetches that are no longer relevant should be cancelled. Search-as-you-type is the classic example: each keystroke fires a new request and you want to abort the previous one.

const controller = new AbortController();

fetch("/api/search?q=ada", { signal: controller.signal })
  .then((res) => res.json())
  .then(console.log)
  .catch((err) => {
    if (err.name === "AbortError") {
      console.log("cancelled");
    } else {
      throw err;
    }
  });

// Cancel before the response arrives
controller.abort();

An aborted fetch rejects with an AbortError. Treat it as a non-error in most cases: it just means the caller no longer cares.

Here is a tiny search helper that always cancels the previous request:

function makeSearcher() {
  let controller;
  return async function search(query) {
    if (controller) controller.abort();
    controller = new AbortController();
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: controller.signal,
      });
      return await res.json();
    } catch (err) {
      if (err.name === "AbortError") return null;
      throw err;
    }
  };
}

AbortController also pairs with a built-in timeout via AbortSignal.timeout(ms):

await fetch("/api/data", { signal: AbortSignal.timeout(5000) });

Five seconds and the request aborts itself.

Common patterns you will reuse

Sending an auth token:

fetch("/api/me", {
  headers: { Authorization: `Bearer ${token}` },
});

Uploading a file:

const form = new FormData();
form.append("avatar", fileInput.files[0]);
await fetch("/api/avatar", { method: "POST", body: form });

Note: do not set Content-Type for FormData. The browser sets it with the right boundary.

Sending an idempotent retry:

async function retry(fn, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try { return await fn(); }
    catch (err) {
      if (i === attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, 200 * 2 ** i));
    }
  }
}

Only retry methods that are safe to retry: GET, PUT (when truly idempotent), and DELETE. Never blindly retry POST.

When to reach for a library

fetch is enough for most apps. Reach for axios, ky, or ofetch only when you need features fetch does not have: automatic JSON parsing, request and response interceptors, progress events for uploads, or a unified retry layer. For greenfield code in 2026, start with native fetch plus a small wrapper. You can always migrate later.

Wrap up

fetch is small on purpose. It transports HTTP and returns a Response. You decide what counts as an error, what shape the body takes, and how to handle cancellation. The patterns are not complicated, but you need to be deliberate. Always check res.ok. Always handle network errors and parse errors separately. Reach for AbortController whenever a stale request could cause a problem. Build one HTTP helper for your codebase and use it everywhere. Once that is in place, the rest of your data-fetching code becomes boring, which is exactly what you want.