Fetching Data from APIs
Calling web APIs from JavaScript with fetch: reading JSON responses, checking status codes, sending data, handling errors and timeouts, and what CORS means for your code.
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-Typeor anAuthorizationheader trigger a preflight request (anOPTIONSrequest) 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.";
}
});