CoreMesh

How to Use the JavaScript Fetch API: Examples You Can Run

JavaScript fetch API by example: GET and POST JSON, auth headers, error handling, AbortController timeouts, and the mistakes that break requests.

How to Use the JavaScript Fetch API: Examples You Can Run

The fetch API is the built-in way to make HTTP requests from JavaScript. You call fetch(url, options), it returns a promise that resolves to a Response, and you read the body with a method like response.json() or response.text(). It's a global function in browser windows and workers, so there's nothing to install or import, and it replaces the callback-based XMLHttpRequest.

Two details cause most of the confusion, and both are about what fetch doesn't do. It doesn't reject the promise when the server answers with a 404 or 500, so you have to check response.ok yourself. And it has no timeout option, so you build one with AbortController. The examples below start with the smallest working call and work up through POST, headers, error handling and timeouts.

The smallest fetch example that works#

This is the pattern from the MDN guide to using fetch, and it's the one I'd copy into any new project:

javascript
async function getData() {
  const url = "https://example.org/products.json";
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }

    const result = await response.json();
    console.log(result);
  } catch (error) {
    console.error(error.message);
  }
}

Four things happen here:

  1. fetch(url) with no options makes a GET request. The first argument can be a string, a URL object, or a Request instance.
  2. await gives you a Response object as soon as the headers arrive. The body hasn't been read yet.
  3. response.ok is true only for statuses in the 200 range. A 404 still resolves the promise, so this check is where you turn HTTP errors into thrown errors.
  4. response.json() parses the body. It's async like every other body method, so it needs its own await.

The try/catch then handles both kinds of failure in one place: network-level rejections and the error you threw for a bad status.

JavaScript fetch GET example: reading status, JSON and text#

The Response object gives you status (the numeric code), ok (a boolean for the 200 range), and a set of body readers. json() parses JSON, text() returns a string. Each reads the whole body before resolving.

You can see how ok and status behave without touching the network, because Response is a constructor you can call yourself. This script builds a successful response and a 404, runs them through the same check, and shows what happens when you try to read a body twice:

javascript
async function handle(response) {
  if (!response.ok) {
    throw new Error(`Response status: ${response.status}`);
  }
  return response.json();
}

async function main() {
  const okResponse = new Response(JSON.stringify({ id: 1, name: "Widget" }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
  const notFound = new Response("not here", { status: 404 });

  console.log("200 ->", "ok:", okResponse.ok, "status:", okResponse.status);
  console.log("404 ->", "ok:", notFound.ok, "status:", notFound.status);

  const copy = okResponse.clone();
  console.log("first read:", await handle(okResponse));
  console.log("clone read:", await handle(copy));

  try {
    await okResponse.json();
  } catch (e) {
    console.log("second read of the same body failed:", e.constructor.name);
  }

  try {
    await handle(notFound);
  } catch (e) {
    console.log("caught:", e.message);
  }
}

main();

The body is a ReadableStream. Once anything has read from it, the stream is disturbed and nothing else can read it. That's why the second json() call fails and why clone() exists. Call clone() before the first read if you need the body twice, for example to cache a copy in a service worker.

For large text responses, you don't have to wait for the whole body. Pipe response.body through a TextDecoderStream and iterate chunks as they arrive:

javascript
const response = await fetch("https://www.example.org/a-large-file.txt");
if (!response.ok) throw new Error(`Response status: ${response.status}`);

const stream = response.body.pipeThrough(new TextDecoderStream());
for await (const chunk of stream) {
  console.log(chunk);
}

You get raw UTF-8 bytes from response.body, which is why the decoder is needed.

JavaScript fetch POST example: sending JSON#

A POST that sends JSON needs three options: the method, a Content-Type header, and a body that's already a string.

javascript
const response = await fetch("https://example.org/post", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ username: "example" }),
});

if (!response.ok) {
  throw new Error(`Response status: ${response.status}`);
}
const result = await response.json();

Fetch won't serialize an object for you. Pass a plain object as body and it's converted with toString(), which gives the server the literal text [object Object]. Always call JSON.stringify() yourself.

The body option accepts a string, ArrayBuffer, TypedArray, DataView, Blob, File, URLSearchParams, FormData, or ReadableStream. The URLSearchParams type is handy for classic form posts because it encodes the pairs for you:

javascript
const response = await fetch("https://example.org/post", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
  },
  // Sent as "username=example&password=password"
  body: new URLSearchParams({ username: "example", password: "password" }),
});

One rule to remember: a GET request can't carry a body. If you need to send data with a GET, it goes in the URL's query string.

You can check what URLSearchParams produces, and how the Headers class cleans up header names and values, without a server:

javascript
const headers = new Headers();
headers.append("Content-Type", "  application/json  ");
headers.append("X-Request-Id", "abc123");

for (const [name, value] of headers) {
  console.log(`${name}: [${value}]`);
}

const form = new URLSearchParams({ username: "example", password: "password" });
console.log("form body:", form.toString());

Setting headers, including Authorization#

The headers option takes either a plain object or a Headers instance. The plain object is shorter and fine for most calls. The Headers object adds input sanitization: it normalizes names to lowercase, strips leading and trailing whitespace from values, and refuses to set forbidden request headers that the browser controls.

An authenticated JSON request with a bearer token looks like this:

javascript
async function getProfile(token) {
  const response = await fetch("https://example.org/me", {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
    },
  });
  if (!response.ok) {
    throw new Error(`Response status: ${response.status}`);
  }
  return response.json();
}

If several calls share the same setup, build a Request object once. The Request() constructor takes the same arguments as fetch(), and you can derive a new request from an existing one while overriding specific options:

javascript
const base = new Request("https://example.org/post", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username: "example1" }),
});

const second = new Request(base, {
  body: JSON.stringify({ username: "example2" }),
});

const r1 = await fetch(base);
const r2 = await fetch(second);

Request bodies are streams too. Sending the same Request object twice fails with a "Body has already been consumed" error. Call request.clone() before the first send if you need to retry the exact request.

Fetch error handling: the two ways a request fails#

Fetch has two distinct failure paths, and robust code handles both.

The promise rejects. This happens for network errors, a bad URL scheme, or an aborted request. You never get a Response. The error lands in your catch block directly.

The promise resolves with a bad status. The server answered, so fetch considers its job done. A 401, 404 or 500 all resolve normally. Your if (!response.ok) check is the only thing that turns these into errors.

A small helper keeps this consistent across a codebase. It reads the error body as text so the message carries whatever the server said:

javascript
async function fetchJSON(url, options = {}) {
  const response = await fetch(url, options);
  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`Response status: ${response.status} ${detail}`);
  }
  return response.json();
}

try {
  const products = await fetchJSON("https://example.org/products.json");
} catch (error) {
  console.error(error.message);
}

If you want to try parsing the error body as JSON and fall back to text, clone the response first. The first read disturbs the stream, and the fallback read would otherwise throw.

The full list of Response properties is on the MDN Response reference.

Fetch timeout and cancellation with AbortController#

There's no timeout option on fetch. The supported mechanism is an AbortController: pass its signal in the options, and call controller.abort() when you want to give up. The promise then rejects with an AbortError.

A timeout is just an abort on a timer:

javascript
async function fetchWithTimeout(url, options = {}, ms = 8000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);

  try {
    const response = await fetch(url, { ...options, signal: controller.signal });
    if (!response.ok) {
      throw new Error(`Response status: ${response.status}`);
    }
    return await response.json();
  } finally {
    clearTimeout(timer);
  }
}

try {
  const data = await fetchWithTimeout("https://example.org/products.json", {}, 5000);
} catch (error) {
  if (error.name === "AbortError") {
    console.error("Request timed out");
  } else {
    console.error(error.message);
  }
}

Two things to note. The return await inside the try matters: if the headers arrive but the body is slow, the abort still fires during response.json(), and that read rejects with AbortError as well. And the finally clears the timer so a fast response doesn't leave a stray abort behind.

User-triggered cancellation uses the same controller. Keep a reference to it and call abort() from a click handler, a route change, or a React effect cleanup:

javascript
const controller = new AbortController();

fetchButton.addEventListener("click", async () => {
  try {
    const response = await fetch("https://example.org/get", {
      signal: controller.signal,
    });
    console.log(`Response: ${response.status}`);
  } catch (e) {
    console.error(`Error: ${e}`);
  }
});

cancelButton.addEventListener("click", () => controller.abort());

The AbortController reference covers the signal object in more detail.

Common mistakes when fetch isn't working#

Most "fetch not working" reports come down to one of these.

  • Treating a 404 as an exception. Fetch resolves on any HTTP status. Without an ok check, your code calls response.json() on an error page and fails with a parse error far from the real cause.
  • Sending an object as the body. body: { a: 1 } becomes [object Object]. Wrap it in JSON.stringify() and set Content-Type: application/json.
  • Forgetting the second await. const data = response.json() gives you a promise, not data. Every body method is async.
  • Putting a body on a GET. Fetch doesn't allow it. Move the data to the query string or switch to POST.
  • Reading a body twice. Streams can only be consumed once. Call response.clone() before the first read, or read once and reuse the parsed value.
  • Reusing a Request with a body. The second fetch(request) throws because the body was consumed. Clone the request first.
  • Setting a forbidden header. Headers the browser manages can't be set from script. The Headers object silently prevents it, so check the network panel rather than your code if a header isn't showing up.
  • Using mode: "no-cors" and expecting it to help. It restricts the method to GET, POST or HEAD and limits the headers you can set. It doesn't make a blocked cross-origin response readable.
  • Letting a timeout fire after success. If you don't clear the timer, abort() runs later and can reject a body read you're still waiting on.

If the promise rejects before you ever see a Response, the problem is at the network level: a bad scheme, no connection, or the browser refusing to send the request. If you get a Response with a surprising status, the problem is on the server side or in what you sent. Checking which of the two happened is the fastest first step. For every option fetch accepts, see the fetch() reference.

Quick reference#

What you want How
GET JSON const r = await fetch(url); if (!r.ok) throw ...; const data = await r.json();
POST JSON fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(obj) })
Form post body: new URLSearchParams({ ... }) with Content-Type: application/x-www-form-urlencoded
Auth header headers: { Authorization: \Bearer ${token}` }`
Plain text body await response.text()
Stream text response.body.pipeThrough(new TextDecoderStream())
Read body twice const copy = response.clone() before the first read
Cancel const c = new AbortController(); fetch(url, { signal: c.signal }); c.abort();
Timeout setTimeout(() => c.abort(), ms) plus clearTimeout in finally
Detect cancel error.name === "AbortError"

Before shipping a fetch call, run through this list:

  1. Every call checks response.ok and throws or handles the status.
  2. Every body read has its own await.
  3. POST bodies are stringified and the Content-Type header matches the format.
  4. Anything that could hang has an AbortController with a cleared timer.
  5. Nothing reads the same body or sends the same Request twice without clone().
  6. The catch block distinguishes AbortError from real failures if the difference matters to the user.

Quick answers

What is the JavaScript fetch API?

It's the built-in, promise-based interface for making HTTP requests from JavaScript. You call fetch() with a URL and optional settings, get back a Response, and read the body with methods like json() or text(). It's available as a global in browser windows and workers.

Why doesn't fetch throw on a 404?

Fetch only rejects for failures like network errors, a bad scheme, or an abort. A server response with any status counts as success to fetch, so you check response.ok or response.status yourself and throw if it's not what you expect.

How do I set a timeout on fetch?

Create an AbortController, pass controller.signal in the fetch options, and call controller.abort() from a setTimeout. The promise rejects with an AbortError, and you should clear the timer once the request finishes.

What's the difference between fetch and GET?

GET is an HTTP method; fetch is the JavaScript function that sends HTTP requests. Calling fetch(url) with no options sends a GET, and you switch to POST, PUT or others with the method option.

Does the same fetch code work in React and Next.js?

Yes. React and Next.js apps call the same fetch function, so GET, POST, headers and AbortController work unchanged inside a component or effect. The one framework-specific habit is aborting the request in the effect's cleanup so an unmounted component doesn't process a late response.

Did this solve it?

One tap. No account.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

On a computer? Scan the code with your phone wallet, or copy the address.

Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.

Discussion

Start the discussion

`code` and ``` blocks work

No account needed. Comments are read before they show up, usually the same day. Your email is never asked for.

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS