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.

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:
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:
fetch(url)with no options makes a GET request. The first argument can be a string, aURLobject, or aRequestinstance.awaitgives you aResponseobject as soon as the headers arrive. The body hasn't been read yet.response.okistrueonly for statuses in the 200 range. A 404 still resolves the promise, so this check is where you turn HTTP errors into thrown errors.response.json()parses the body. It's async like every other body method, so it needs its ownawait.
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:
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:
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.
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:
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:
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:
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:
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:
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:
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:
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
okcheck, your code callsresponse.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 inJSON.stringify()and setContent-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
Headersobject 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:
- Every call checks
response.okand throws or handles the status. - Every body read has its own
await. - POST bodies are stringified and the
Content-Typeheader matches the format. - Anything that could hang has an
AbortControllerwith a cleared timer. - Nothing reads the same body or sends the same
Requesttwice withoutclone(). - The
catchblock distinguishesAbortErrorfrom 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.
Sorry about that. What was missing?
from 0 readers
Saved you an hour? Send a tip.
USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.
Network
0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
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
Next note