Articles

The Fetch API Explained: Making HTTP Requests in JavaScript

The Fetch API is JavaScript's built-in interface for making HTTP requests. How it works, its promise-based flow, and where it trips people up.

Takina Takina · · 4 min read
Abstract illustration of an API connecting two endpoints

The Fetch API is the built-in browser (and Node.js) interface for making HTTP requests from JavaScript. It replaced XMLHttpRequest as the standard way to talk to a server, returning a Promise that resolves once the response headers arrive — no callback pyramids, no third-party library required.

The basic shape

A minimal fetch call looks like this:

const response = await fetch("/api/users/42");
const data = await response.json();

That’s two awaits, not one, and it’s the single most common source of confusion for people new to Fetch. The promise returned by fetch() resolves as soon as the server has sent response headers — it does not wait for the full body to download. To get the actual payload, you call a body-reading method (.json(), .text(), .blob(), .arrayBuffer(), or .formData()), which returns its own promise.

This two-step design lets you inspect status codes and headers before committing to parsing a potentially large body, and it’s why async/await reads so naturally with Fetch — each step is just another promise.

Fetch does not reject on HTTP errors

This trips up almost everyone at least once: fetch() only rejects on network failure — DNS errors, connection refused, CORS blocks. A 404 Not Found or a 500 Internal Server Error is still a “successful” fetch from the API’s point of view. The promise resolves; it’s up to you to check response.ok (true for status codes 200–299) or response.status yourself:

const response = await fetch("/api/users/42");
if (!response.ok) {
  throw new Error(`Request failed: ${response.status}`);
}
const data = await response.json();

Skipping this check is a common source of silent bugs — a failed request looks identical to a successful one until something tries to parse an error page as JSON and throws a confusing parsing error instead of a clear HTTP error.

Configuring a request

The second argument to fetch() is an options object covering method, headers, and body:

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

Other useful options include credentials (whether to send cookies — "same-origin" by default, "include" for cross-origin requests that need them) and signal, which wires in an AbortController for cancellation:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);

See our deep dive on AbortController for cancellation patterns beyond simple timeouts, including cancelling a fetch when a component unmounts or a newer request supersedes an older one.

Streaming and large responses

response.body exposes a ReadableStream, which lets you process a response incrementally instead of buffering the whole thing in memory — useful for large downloads or for consuming a server-sent event-style stream chunk by chunk. This is the same streaming primitive covered in our Node.js streams article, and it’s what powers incremental rendering of large JSON payloads or progressively displaying an LLM’s streamed response.

For genuinely long-lived, server-pushed updates rather than a single request/response, Server-Sent Events or WebSockets are usually a better fit than trying to keep a single fetch open indefinitely.

Fetch vs XMLHttpRequest vs axios

FetchXMLHttpRequestaxios (library)
Built-inYesYesNo (dependency)
API stylePromise-basedEvent/callback-basedPromise-based
Rejects on HTTP errorNoNoYes, by default
Request cancellationAbortController.abort()AbortController or cancel tokens
Automatic JSON parsingNo, manual .json()NoYes
Progress events (upload)LimitedYesYes

Fetch is the right default for most modern code — it needs no dependency and covers the vast majority of use cases. Libraries like axios still earn their keep when you want automatic JSON parsing, request/response interceptors, or need upload progress events, which Fetch doesn’t expose directly.

Common pitfalls

  • Forgetting the second await. const data = await fetch(url).json() is a bug — fetch(url) returns a promise, and .json isn’t a method on a promise. It has to be (await fetch(url)).json() or two separate await statements.
  • Not checking response.ok. Covered above, but worth repeating: a 404 page is a resolved promise, not a rejected one.
  • CORS confusion. A blocked cross-origin request surfaces as a generic “Failed to fetch” network error with no status code — the browser doesn’t expose the underlying reason to JavaScript for security reasons. Check the browser’s network tab or console for the actual CORS error, which contains more detail than the thrown exception.
  • Sending JSON without the header. If you JSON.stringify a body but forget Content-Type: application/json, many servers will fail to parse it as JSON and treat it as plain text instead.

The takeaway

The Fetch API is a promise-based, two-step interface: one await for the response to start arriving, a second to read the body in the format you need. It resolves on any completed HTTP exchange — you’re responsible for checking response.ok yourself — and it composes cleanly with AbortController for cancellation and ReadableStream for incremental processing. For typical request/response traffic it’s the right default; reach for a library only when you need conveniences like automatic JSON parsing or interceptors out of the box.

Takina Takina · · 4 min read

TypeScript Abstract Classes, Explained

Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.

#TypeScript #JavaScript #Web Development
Takina Takina · · 4 min read

What Is a Lockfile? Reproducible Dependency Installs

A lockfile records the exact dependency versions your package manager resolved, so every install — from your laptop to CI — reproduces the same tree.

#JavaScript #Web Development #Developer Tools
Takina Takina · · 3 min read

TypeScript's never Type, Explained

never represents values that can't exist — it marks unreachable code, exhaustive switches, and functions that always throw or loop forever.

#TypeScript #JavaScript #Web Development