JavaScript Fetch API

6 minute read

fetch is the modern, promise-based HTTP client built into every browser and Node 18+. It replaces XMLHttpRequest. The API is small but has a few “gotchas” that surprise newcomers — most famously, fetch does not reject on HTTP 4xx/5xx. This chapter covers the basics, JSON handling, request options, error patterns, cancellation, and a production-grade wrapper.

A Minimal Request

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

Two awaits because there are two async steps:

  1. fetch(url, options) — sends the request, resolves when headers arrive.
  2. res.json() / res.text() / res.blob() — reads the body to completion.

The Response Object

const res = await fetch("/api/users/1");

res.ok;              // true if status is 200-299
res.status;          // 200
res.statusText;      // "OK"
res.headers.get("content-type");   // "application/json; charset=utf-8"
res.url;             // resolved URL (after redirects)
res.redirected;      // bool
res.type;            // "basic" | "cors" | "opaque" | …

Reading the Body

You can consume the body once as one of these:

Method Resolves to
res.json() parsed JSON value
res.text() string
res.blob() Blob (e.g. for downloads)
res.arrayBuffer() binary ArrayBuffer
res.formData() FormData
res.body a ReadableStream (for streaming)
const data = await res.json();
const text = await res.text();   // ❌ throws — body already consumed

To do both, clone() first or just decide upfront which you need.

The fetch Doesn’t Reject on HTTP Errors

This is the most quoted Fetch gotcha:

const res = await fetch("/api/missing");
// res is a real Response — fetch did NOT throw
res.status;   // 404
res.ok;       // false

await res.json();   // probably throws — body isn't JSON

fetch only rejects on network failures: DNS, CORS, abort, offline. HTTP 4xx and 5xx are still successful resolutions as far as the promise is concerned. You must check res.ok yourself:

const res = await fetch("/api/users/1");
if (!res.ok) {
  throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const user = await res.json();

Warning: Always check res.ok before reading the body. This is the #1 mistake when picking up Fetch.

Request Options

const res = await fetch("/api/users", {
  method:  "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${token}`,
  },
  body: JSON.stringify({ name: "Ada", age: 37 }),
  credentials: "include",        // "omit" | "same-origin" | "include"
  cache:       "no-cache",
  mode:        "cors",           // "no-cors" | "cors" | "same-origin"
  redirect:    "follow",         // "follow" | "error" | "manual"
  signal:      ctrl.signal,
});
Option Common values Use
method "GET", "POST", "PUT", "PATCH", "DELETE" HTTP verb
headers object or Headers request headers
body string, FormData, Blob, URLSearchParams, ReadableStream request payload
credentials "include" to send cookies cross-origin cookies / auth
signal AbortSignal cancellation, timeouts
cache "no-cache", "reload", "force-cache" cache control

Sending JSON

const res = await fetch("/api/users", {
  method:  "POST",
  headers: { "Content-Type": "application/json" },
  body:    JSON.stringify({ name: "Ada", role: "admin" }),
});

Three things to remember:

  1. Content-Type: application/json is not auto-set — you set it
  2. body must be a string — JSON.stringify it
  3. Check res.ok, then res.json()

Sending Form Data

const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("title", "My upload");

const res = await fetch("/api/upload", {
  method: "POST",
  body:   form,            // browser sets Content-Type with boundary
});

Do not set Content-Type yourself for FormData — the browser fills in multipart/form-data; boundary=… for you.

For URL-encoded form posts:

const params = new URLSearchParams({ q: "javascript", page: 1 });

await fetch("/search", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body:   params.toString(),
});

Query Strings

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

// Or via the URL object
const url = new URL("/api/search", "https://example.com");
url.searchParams.set("q", "ada");
url.searchParams.set("page", "2");
const res2 = await fetch(url);

Headers

const h = new Headers();
h.set("Authorization", `Bearer ${token}`);
h.append("X-Trace", "abc");
h.get("Authorization");
h.has("X-Trace");

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

A plain object works just as well for simple cases.

Cancellation and Timeouts — AbortController

const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 5000);

try {
  const res  = await fetch("/api/slow", { signal: ctrl.signal });
  const data = await res.json();
  console.log(data);
} catch (err) {
  if (err.name === "AbortError") console.log("aborted");
  else throw err;
} finally {
  clearTimeout(timer);
}

// Modern shortcut (browsers + Node 18+):
await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });

AbortSignal.timeout(ms) builds the controller for you — much shorter.

Streaming the Response

Use res.body when you want chunks as they arrive (e.g., large CSV, server-sent-events-style feeds):

const res = await fetch("/api/stream");
const reader = res.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log(decoder.decode(value, { stream: true }));
}

Uploading Progress (Streamed Request)

The Fetch spec supports streaming request bodies via ReadableStream, but browser support varies. For now, use XMLHttpRequest if you need a true upload progress bar — that’s the one remaining XHR niche.

A Realistic Example — A Generic API Wrapper

async function api(path, {
  method  = "GET",
  body,
  query,
  headers = {},
  timeout = 10_000,
  signal,
} = {}) {

  const url = new URL(path, location.origin);
  if (query) Object.entries(query).forEach(([k, v]) =>
    v !== undefined && url.searchParams.set(k, v));

  const finalHeaders = { Accept: "application/json", ...headers };
  let payload;
  if (body !== undefined) {
    if (body instanceof FormData || body instanceof Blob) {
      payload = body;                       // let browser set Content-Type
    } else {
      finalHeaders["Content-Type"] = "application/json";
      payload = JSON.stringify(body);
    }
  }

  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), timeout);
  // Combine caller's signal with ours if provided
  if (signal) signal.addEventListener("abort", () => ctrl.abort(), { once: true });

  try {
    const res = await fetch(url, {
      method, headers: finalHeaders, body: payload, signal: ctrl.signal,
    });

    if (!res.ok) {
      const errText = await res.text();
      throw new Error(`HTTP ${res.status}: ${errText || res.statusText}`);
    }

    if (res.status === 204) return null;     // No Content

    const ct = res.headers.get("content-type") ?? "";
    if (ct.includes("application/json")) return await res.json();
    if (ct.startsWith("text/"))          return await res.text();
    return await res.blob();
  } finally {
    clearTimeout(timer);
  }
}

// Usage
const user   = await api("/api/users/1");
const search = await api("/api/search", { query: { q: "ada", page: 2 } });
const made   = await api("/api/users", { method: "POST", body: { name: "Ada" } });

Query-builder, auto-JSON, timeout, abort wiring, status-driven body parsing — about 30 lines for a production-grade client.

Common Mistakes

// ❌ Assuming a 404 throws
const res = await fetch("/missing");
const data = await res.json();   // throws on non-JSON body

// ✅ Always check
if (!res.ok) throw new Error(`HTTP ${res.status}`);

// ❌ Setting Content-Type for FormData
fetch("/upload", { method: "POST", headers: { "Content-Type": "multipart/form-data" }, body: form });
// → breaks boundary

// ❌ Forgetting to JSON.stringify
fetch("/api", { method: "POST", body: { x: 1 } });
// → body becomes the string "[object Object]"

// ❌ Reading the body twice
const t = await res.text();
const j = await res.json();   // ❌ already consumed

// ✅ Clone if needed
const j = await res.clone().json();

// ❌ No timeout — request hangs forever on a slow server

Best Practices Checklist

  • ✅ Always check res.ok (or res.status) before reading the body
  • ✅ Build URLs with URL + URLSearchParams — never string concatenate
  • ✅ Set Content-Type: application/json and JSON.stringify(body) for JSON posts
  • ✅ Let the browser set Content-Type for FormData / Blob
  • ✅ Add a timeout via AbortSignal.timeout(ms) to every external call
  • ✅ Wrap fetch in a small project-wide api() helper
  • ❌ Don’t assume non-2xx throws — it doesn’t
  • ❌ Don’t consume the body twice without clone()
  • ❌ Don’t ignore AbortError separately from other failures

What’s Next?

You can talk to any HTTP endpoint. Now the universal companion to async work — error handling — try/catch/finally, throwing your own errors, custom error classes, and async error patterns.

← Previous: JS Async/Await Next: JS Error Handling →

Updated: