The Fetch API

The Fetch API is the standard way for JavaScript to make HTTP requests: the global function fetch(), which returns a promise of a response, together with the Request, Response and Headers classes. It is defined by the WHATWG Fetch Standard, is built into every current browser, and has been built into Node.js since version 18 (no longer experimental since version 21). It replaced the older XMLHttpRequest, which you will still meet in old code.

In a browser, inside an async function or a module, calling an API takes two awaits: one for the response to arrive, and one for its body to be read and parsed:

const response = await fetch("https://api.example.com/tickets?status=open");
const tickets = await response.json();
console.log(tickets.length, "open tickets");

The same with .then() chains, as you will find it in older code:

fetch("https://api.example.com/tickets?status=open")
  .then((response) => response.json())
  .then((tickets) => console.log(tickets.length, "open tickets"))
  .catch((error) => console.error("Request failed:", error));

The runnable examples on this page start a tiny API server of their own on the local machine (127.0.0.1, on any free port), so they work without an internet connection, and stop it at the end. The fetch part is the same as in a browser:

import http from "node:http";
import { once } from "node:events";

// A tiny local API so the example needs no internet connection.
const tickets = [
  { id: "INC-30412", summary: "Portal login slow", priority: 2 },
  { id: "INC-30415", summary: "Meter API timeouts", priority: 1 },
];
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify(tickets));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;

// The part that matters: fetch, check, read the JSON body.
const response = await fetch(`${base}/api/tickets`);
console.log(response.status, response.ok);
console.log(response.headers.get("content-type"));

const data = await response.json();
for (const ticket of data) {
  console.log(`${ticket.id} (P${ticket.priority}) ${ticket.summary}`);
}

server.close();
200 true
application/json
INC-30412 (P2) Portal login slow
INC-30415 (P1) Meter API timeouts

The Response object

A Response object is what a fetch() promise fulfills with: the status line and headers of the HTTP response, available at once, plus a body that you still have to read. It arrives as soon as the headers are in, possibly before the whole body has been downloaded. Its most used members:

Member Gives you
status, statusText the status code and its reason phrase, such as 404 and "Not Found" (over HTTP/2 and HTTP/3 the reason phrase is empty)
ok true for a status from 200 to 299
headers.get(name) one response header; names are case-insensitive
json(), text(), blob(), formData(), arrayBuffer() a promise of the body, parsed in the chosen way
bodyUsed whether the body has already been read

The body is a stream, so it can be read only once. A second read fails with a TypeError; if you need the body twice, read it as text and parse that, or call response.clone() before the first read.

import http from "node:http";
import { once } from "node:events";

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end('{"open": 2}');
});
server.listen(0, "127.0.0.1");
await once(server, "listening");

const response = await fetch(`http://127.0.0.1:${server.address().port}/`);
console.log(response instanceof Response, response.bodyUsed);
console.log(await response.text());
console.log("bodyUsed:", response.bodyUsed);

try {
  await response.json(); // the body is a stream: it can only be read once
} catch (error) {
  console.log("Second read:", error.name);
}

server.close();
true false
{"open": 2}
bodyUsed: true
Second read: TypeError

response.json() parses the body as JSON and rejects with a SyntaxError when the body is not valid JSON, which often happens when a server answers an error with an HTML page. Check the status first, and check headers.get("content-type") if an API is unreliable.

Checking the status: response.ok

The most common mistake with fetch() is assuming it fails on an HTTP error. It does not. The promise rejects only when no HTTP response arrives at all: the network is down, the name does not resolve, the connection is refused, or the request is blocked by CORS or aborted. A server that answers 404 Not Found or 500 Internal Server Error did answer, so the promise fulfills.

response.ok is the Boolean property of a response that is true when the status is in the success range 200–299 and false otherwise. Check it after every request. A small helper that turns an HTTP error into a thrown error keeps the rest of the code simple:

import http from "node:http";
import { once } from "node:events";

const server = http.createServer((req, res) => {
  if (req.url === "/api/tickets/INC-30412") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ id: "INC-30412", status: "open" }));
  } else {
    res.writeHead(404, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ error: "No such ticket" }));
  }
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;

// fetch() does not reject on 404: check response.ok yourself.
async function getJson(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} ${response.statusText} for ${new URL(url).pathname}`);
  }
  return response.json();
}

const missing = await fetch(`${base}/api/tickets/INC-99999`);
console.log("fetch resolved anyway:", missing.status, missing.ok);

for (const id of ["INC-30412", "INC-99999"]) {
  try {
    const ticket = await getJson(`${base}/api/tickets/${id}`);
    console.log("Found:", ticket);
  } catch (error) {
    console.log("Failed:", error.message);
  }
}

server.close();
fetch resolved anyway: 404 false
Found: { id: 'INC-30412', status: 'open' }
Failed: HTTP 404 Not Found for /api/tickets/INC-99999
What happened Where you notice it
Network error, refused connection, CORS block, abort await fetch() throws (a TypeError, or an AbortError or TimeoutError)
The server answers 4xx or 5xx response.ok is false; nothing throws
The body is not the JSON you expected await response.json() throws a SyntaxError

Sending data

The second argument of fetch() is an options object. For anything other than a plain GET you set method, usually some headers, and a body. To send JSON, turn the object into a string with JSON.stringify() and say so in the Content-Type header:

import http from "node:http";
import { once } from "node:events";

// Local API: accepts a new ticket as JSON and answers 201 Created.
const server = http.createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;
  const ticket = { id: "INC-30420", ...JSON.parse(body), status: "open" };
  res.writeHead(201, {
    "Content-Type": "application/json",
    Location: `/api/tickets/${ticket.id}`,
  });
  res.end(JSON.stringify(ticket));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;

const response = await fetch(`${base}/api/tickets`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ summary: "Invoice PDF missing", priority: 3 }),
});

console.log(response.status, response.statusText);
console.log("Location:", response.headers.get("location"));
console.log(await response.json());

server.close();
201 Created
Location: /api/tickets/INC-30420
{
  id: 'INC-30420',
  summary: 'Invoice PDF missing',
  priority: 3,
  status: 'open'
}

The server answered 201 Created with a Location header for the new ticket, as a REST API should. Other methods such as PUT, PATCH and DELETE work the same way. Authentication usually goes in a header too, for example Authorization: Bearer …; see API authentication.

Sending form data

FormData is a built-in class that holds a set of name–value pairs (including files) in the format of an HTML form. Passed as a fetch body, it is sent as multipart/form-data. Its sibling URLSearchParams sends the simpler form encoding, application/x-www-form-urlencoded. In both cases fetch() sets the Content-Type header itself:

import http from "node:http";
import { once } from "node:events";

// Local API: reports how the request body was encoded.
const server = http.createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;
  const type = req.headers["content-type"].split(";")[0];
  res.end(type === "multipart/form-data" ? type : `${type}\n  ${body}`);
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const url = `http://127.0.0.1:${server.address().port}/api/tickets`;

const form = new FormData();
form.append("summary", "Billing down");
form.append("priority", "1");

let response = await fetch(url, { method: "POST", body: form });
console.log("FormData:       ", await response.text());

const params = new URLSearchParams({ summary: "Billing down", priority: "1" });
response = await fetch(url, { method: "POST", body: params });
console.log("URLSearchParams:", await response.text());

server.close();
FormData:        multipart/form-data
URLSearchParams: application/x-www-form-urlencoded
  summary=Billing+down&priority=1

In a browser, new FormData(form) collects every named field of a form element in one go, which combines well with the submit listener from The DOM and Events:

const form = document.querySelector("#new-ticket");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const response = await fetch("/api/tickets", {
    method: "POST",
    body: new FormData(form), // every named field of the form
  });
  console.log(response.status);
});

Query strings

For GET requests, data goes in the query string. Do not glue it together with string concatenation: a value containing & or # would break the URL. The URL class encodes values for you, and fetch() accepts the URL object directly:

const url = new URL("https://api.example.com/tickets");
url.searchParams.set("status", "open");
url.searchParams.set("q", "login & SSO");
console.log(url.href);
https://api.example.com/tickets?status=open&q=login+%26+SSO

Errors, timeouts and canceling

fetch() has no timeout option, and a request to a server that never answers can wait for a very long time. The tool for giving up is an abort signal. An AbortController is an object whose signal property you pass to fetch() (and to other APIs that accept one); calling controller.abort() cancels the request, and the fetch promise rejects with an AbortError. For the common case of a time limit, AbortSignal.timeout(ms) creates a signal that aborts by itself and makes fetch reject with a TimeoutError.

import http from "node:http";
import { once } from "node:events";

// Local API that takes 2 seconds to answer every request.
const server = http.createServer((req, res) => {
  const timer = setTimeout(() => res.end("finally"), 2000);
  res.on("close", () => clearTimeout(timer)); // client gave up: stop waiting
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${server.address().port}`;

// 1. A timeout with AbortSignal.timeout()
try {
  await fetch(`${base}/slow`, { signal: AbortSignal.timeout(100) });
} catch (error) {
  console.log("1.", error.name);
}

// 2. Canceling by hand with an AbortController
const controller = new AbortController();
setTimeout(() => controller.abort(), 50); // e.g. the user clicked Cancel
try {
  await fetch(`${base}/slow`, { signal: controller.signal });
} catch (error) {
  console.log("2.", error.name);
}
server.closeAllConnections();
server.close();

// 3. A port where nothing listens: a network error, not an HTTP status
const probe = http.createServer().listen(0, "127.0.0.1");
await once(probe, "listening");
const closedPort = probe.address().port;
probe.close();
await once(probe, "close");
try {
  await fetch(`http://127.0.0.1:${closedPort}/api/tickets`);
} catch (error) {
  console.log("3.", error.name, error.message, error.cause.code);
}
1. TimeoutError
2. AbortError
3. TypeError fetch failed ECONNREFUSED

The third case is a network error: no server listens on that port, so there is no response and no status code. Node.js reports it as TypeError: fetch failed with the underlying reason in error.cause. Browsers deliberately give fewer details, for example TypeError: Failed to fetch in Chrome; the reason is visible only in the developer tools. Telling the three cases apart lets you show the right message: "took too long", nothing (the user canceled), or "cannot reach the server".

CORS: what it means for your code

In a browser, the same-origin policy stops a script from reading responses from another origin (another scheme, host or port) unless that server allows it with CORS headers. A script on https://portal.example.com that fetches from https://api.example.com is making a cross-origin request. If the API does not send a matching Access-Control-Allow-Origin header, the browser blocks the response and the fetch promise rejects. The console shows a message such as:

Access to fetch at 'https://api.example.com/tickets' from origin 'https://portal.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the
requested resource.

What follows for the code you write:

  • CORS is fixed on the server. The API must allow your origin. No fetch option on the client can switch the check off; that is the point of it.
  • mode: "no-cors" is not a fix. It turns the response into an opaque one: no error, but also no status and no readable body.
  • Requests with a JSON Content-Type or an Authorization header trigger a preflight request (an OPTIONS request) first. The server must answer that one correctly too.
  • Cookies are not sent on cross-origin requests unless you ask for it, and the server explicitly allows credentials:
const response = await fetch("https://api.example.com/tickets", {
  credentials: "include", // send cookies with this cross-origin request
});

CORS is a browser rule. The same request from Node.js, curl or Python is not checked, which is why "it works in curl but not in the browser" is the classic sign of a CORS problem. During development, the simplest solution is often to serve the page and the API from the same origin, for example through a development proxy. See the classic "CORS error" for a diagnosis routine.

Showing results on the page

A real page has to handle three states: loading, success and failure. This browser code loads the open tickets, renders them safely with textContent, gives up after eight seconds, and shows users a plain message while logging the details for developers:

const list = document.querySelector("#tickets");
const status = document.querySelector("#status");

async function loadTickets() {
  status.textContent = "Loading…";
  try {
    const response = await fetch("/api/tickets?status=open", {
      signal: AbortSignal.timeout(8000),
    });
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    const tickets = await response.json();

    list.replaceChildren(
      ...tickets.map((ticket) => {
        const item = document.createElement("li");
        item.textContent = `${ticket.id} ${ticket.summary}`; // text, not HTML
        return item;
      }),
    );
    status.textContent = `${tickets.length} open tickets`;
  } catch (error) {
    console.error(error); // details for developers
    status.textContent = "Could not load the tickets. Try again later."; // for users
  }
}

loadTickets();

For a search box that fetches as the user types, an AbortController cancels the previous request whenever a new one starts. Without it, a slow answer to an old search could arrive last and overwrite the results of the newest one:

const searchBox = document.querySelector("#search");
const status = document.querySelector("#status");
let controller = null;

searchBox.addEventListener("input", async () => {
  controller?.abort(); // cancel the previous, now outdated request
  controller = new AbortController();

  const url = new URL("/api/tickets", location.origin);
  url.searchParams.set("q", searchBox.value);
  try {
    const response = await fetch(url, { signal: controller.signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    showResults(await response.json()); // builds the list as in loadTickets()
  } catch (error) {
    if (error.name === "AbortError") return; // expected: a newer search started
    status.textContent = "Search failed. Try again.";
  }
});