Become a Professional Frontend Developer
7 min read

Fetch & HTTP: Talking to APIs from the Browser

A complete, practical guide to HTTP and the Fetch API — the request/response model, methods and status codes, headers, fetch with GET and POST, sending and parsing JSON, why fetch doesn't reject on 404, error handling, AbortController and timeouts, CORS, and the common mistakes — with hands-on exercises and solutions.

Almost every real app talks to a server: load data, submit a form, save a change. That conversation happens over HTTP, and in the browser you drive it with the Fetch API. Understanding both — the protocol's vocabulary and fetch's quirks — is what turns "I copied a fetch snippet" into "I can talk to any API and handle whatever it throws back." (This leans on promises and async/await, since fetch is promise-based.)

Two things trip everyone up about fetch. First: it only rejects on a network failure, not on a 404 or 500 — a "successful" promise can still be an error response, so you must check response.ok yourself. Second: the body arrives as a stream, so you await response.json() as a second step.

The HTTP Model: Request and Response

Every exchange is one request from the client and one response from the server. A request has a method (the verb), a URL, headers (metadata), and optionally a body. A response has a status code, headers, and usually a body.

The common methods map to intentions:

  • GET — read data (no body). The default.
  • POST — create something / submit data (has a body).
  • PUT / PATCH — replace / partially update a resource.
  • DELETE — remove a resource.

Status codes come in ranges — learn the families, not every number:

  • 2xx success — 200 OK, 201 Created, 204 No Content.
  • 3xx redirect — 301, 304 Not Modified.
  • 4xx client error (you sent something wrong) — 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found.
  • 5xx server error (their fault) — 500 Internal Server Error, 503 Unavailable.

A Basic GET

fetch(url) returns a promise for a Response. Reading the body is a second await, because the body streams in:

const response = await fetch("https://api.example.com/users");
const users = await response.json();   // parse the JSON body
console.log(users);

response.json() is itself async (it returns a promise). Other body readers exist: response.text() for plain text, response.blob() for binary (images, files), response.formData().

The response.ok Trap

This is the single most important fetch gotcha. fetch only rejects when the request can't be made at all (no network, DNS failure, CORS block). A 404 or 500 is a successful fetch — the server answered — so the promise fulfills. You have to check the status yourself:

const res = await fetch("/api/user/999");
// res.ok is false here (404), but no error was thrown!

if (!res.ok) {
  throw new Error(`HTTP ${res.status} – ${res.statusText}`);
}
const user = await res.json();

response.ok is true for any 2xx status. Forgetting this check means your code happily tries to parse an error page as if it were valid data.

Sending Data with POST

To send data, pass an options object: set the method, a Content-Type header, and a body. JSON bodies must be stringified by hand:

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

if (!res.ok) throw new Error(`Create failed: ${res.status}`);
const created = await res.json();

The Content-Type header tells the server how to interpret the body; JSON.stringify turns your object into the string that gets sent. For file uploads or form posts, use a FormData object as the body instead (and don't set Content-Type — the browser sets it with the right boundary).

Headers

Headers carry metadata on both requests and responses — content type, authentication, caching. Common request headers:

const res = await fetch("/api/data", {
  headers: {
    "Authorization": `Bearer ${token}`,  // auth
    "Accept": "application/json",          // "I want JSON back"
  },
});

// reading a response header
const type = res.headers.get("Content-Type");

A Reusable, Robust Helper

Most apps wrap fetch once so the ok check and JSON parsing aren't repeated everywhere:

async function api(url, options = {}) {
  const res = await fetch(url, {
    headers: { "Content-Type": "application/json", ...options.headers },
    ...options,
  });
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText} on ${url}`);
  }
  return res.status === 204 ? null : res.json(); // 204 has no body
}

// usage
const user = await api("/api/user/1");
const created = await api("/api/users", { method: "POST", body: JSON.stringify(data) });

Cancelling and Timeouts with AbortController

fetch has no built-in timeout. To cancel a request (a slow call, or a search the user has moved past), use an AbortController and pass its signal:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000); // 5s timeout

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

Aborting causes the fetch promise to reject with an AbortError. This is the standard pattern for timeouts and for cancelling stale requests (e.g. type-ahead search where only the latest query matters). Modern browsers also offer AbortSignal.timeout(5000) as a shortcut.

CORS, Briefly

When your page requests a different origin (domain/port/protocol), the browser enforces CORS: the server must send Access-Control-Allow-Origin headers permitting your origin, or the browser blocks the response and fetch rejects. CORS is enforced by the browser, configured on the server — so a CORS error is something the API has to allow, not something you can fix purely in client code. (Calling your own same-origin API avoids it entirely.)

Common Mistakes

  • Not checking response.ok — treating a 404/500 as success and parsing an error page.
  • Forgetting JSON.stringify on the body, or forgetting the Content-Type: application/json header.
  • Forgetting await response.json()response.json() returns a promise, not the data.
  • Expecting fetch to reject on HTTP errors — it only rejects on network/CORS failures.
  • Setting Content-Type manually for FormData — let the browser set it (boundary needed).
  • No timeout/cancellation — slow or stale requests pile up; use AbortController.
  • Trying to fix a CORS error in the frontend — it's a server configuration issue.
  • Putting secrets (API keys) in client-side fetch — anything in the browser is visible to users.

Exercises

Try each before opening the solution.

Exercise 1 — Safe GET

Fetch /api/profile and return the parsed JSON, throwing a clear error if the response isn't OK.

Show solution
async function getProfile() {
  const res = await fetch("/api/profile");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

The res.ok check is the crucial line — without it a 404 would slip through and res.json() would try to parse an error body.

Exercise 2 — POST some JSON

Send { title: "Hello" } to /api/posts as JSON and return the created object.

Show solution
async function createPost(title) {
  const res = await fetch("/api/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title }),
  });
  if (!res.ok) throw new Error(`Create failed: ${res.status}`);
  return res.json();
}

Three required pieces for a JSON POST: the method, the Content-Type header, and a JSON.stringify-ed body.

Exercise 3 — Add a timeout

Fetch /api/slow but abort if it takes more than 4 seconds.

Show solution
async function getWithTimeout() {
  const res = await fetch("/api/slow", { signal: AbortSignal.timeout(4000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}
// (or the AbortController + setTimeout pattern for older browsers)

AbortSignal.timeout(4000) aborts the request after 4s, rejecting the promise with a TimeoutError/AbortError you can catch.

Exercise 4 — Why did this "work" but break?

const res = await fetch("/api/user/99999");
const user = await res.json();
showName(user.name);   // throws: cannot read 'name' of undefined

What went wrong, and how do you fix it?

Show solution

The user doesn't exist, so the server returned 404 with an error body (not a user). Because fetch doesn't reject on 404, the code parsed the error body and user.name is undefined. Fix by checking res.ok before parsing:

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

The Mental Model to Keep

HTTP is a request (method + URL + headers + optional body) and a response (status + headers + body). In the browser, fetch drives it and returns a promise for a Response — but with two non-obvious rules burned in: it only rejects on network/CORS failure, so always check response.ok and throw yourself; and the body is read separately with await response.json() (or .text()/.blob()). To send data, set method, the Content-Type header, and a JSON.stringify-ed body. Wrap it once in a helper that centralises the ok check, add AbortController for timeouts and cancellation, and remember CORS is the server's job, not yours. Master those and you can integrate with any API the web throws at you.