Almost every real web app needs data it doesn’t already have on the page: a list of products from a store’s backend, the current weather, a user’s profile, search results. That data lives on a server, and your JavaScript has to go fetch it across the network. The tool for that job, built right into every modern browser, is the Fetch API.
fetch() lets you send an HTTP request and read whatever comes back — usually JSON. It’s the modern replacement for the old, clunky XMLHttpRequest, and it’s built on promises, so it fits naturally with the async/await syntax you’ve probably already met. If the words “promise” and “async” still feel hazy, that’s fine — we’ll use them gently here and explain as we go.
A first request
Here’s the smallest useful fetch you can write. We’ll request some data and print it:
async function loadUser() {
const response = await fetch("https://api.example.com/users/1");
const data = await response.json();
console.log(data);
}
loadUser();
Three lines do all the work, and there are exactly two awaits — that’s the part worth slowing down for:
await fetch(url)sends the request and waits for the server to respond. It hands you back aResponseobject (the headers, the status code, the body stream) — not the data itself yet.await response.json()reads the response body and parses it from JSON text into a real JavaScript object or array.
That two-step shape catches a lot of people off guard the first time, so let’s make it stick.
fetch resolves twice — you need both awaits
A common early mistake is expecting await fetch(url) to give you the data directly. It doesn’t. The first await gives you a Response object describing the reply; the body hasn’t even been read yet. You then call await response.json() (or .text()) to actually pull the data out. Two awaits, two steps. Skip the second one and you’ll be staring at a confusing Response { ... } or an unresolved promise instead of your data.
fetch is built on promises
The reason all those awaits show up is that fetch() returns a promise — a placeholder for a value that isn’t ready yet, because the network takes time. await simply pauses the function until that promise settles, then gives you the result. (We cover promises and async/await more fully in their own articles; for fetch you mostly just need the pattern above.)
You can write fetch with .then() chains instead of async/await, and you’ll still see plenty of that style in the wild:
fetch("https://api.example.com/users/1")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error(error));
This does the same thing as the async/await version. Both are valid, but async/await reads top-to-bottom like ordinary code, so we’ll use it for the rest of this article. Pick whichever your team prefers — just don’t mix the two styles in a single function and confuse yourself.
Reading the response: .json(), .text(), and friends
A Response doesn’t assume what format the body is in — you choose how to read it. The method you call depends on what the server sent:
const response = await fetch(url);
const obj = await response.json(); // parse JSON → object/array
const str = await response.text(); // raw text (HTML, plain text, CSV)
const blob = await response.blob(); // binary data (images, files)
response.json() is by far the most common, because most APIs speak JSON. If you’re not sure what an endpoint returns, start with .text() and console.log it — you’ll see the raw payload and can decide from there.
You can only read the body once
Each of these methods consumes the response body — it’s a one-time stream. Call response.json() and then try response.text() on the same response, and the second call throws an error because the body’s already been drained. Read it once, into one variable, and reuse that variable. If you genuinely need the body twice, response.clone() it first.
Handling errors — the part everyone gets wrong
Here’s the single most surprising thing about fetch, and the source of countless bugs: fetch does not reject on HTTP error statuses like 404 or 500. The promise only rejects if the request couldn’t be made at all — no network, DNS failure, blocked by the browser. A 404 Not Found or a 500 Internal Server Error is, as far as fetch is concerned, a perfectly successful round trip. The server answered; the answer just happens to be “no.”
That means a try/catch alone is not enough. You also have to check response.ok (or response.status) yourself:
async function loadUser(id) {
try {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Could not load user:", error.message);
return null;
}
}
response.ok is a convenient boolean — it’s true for any status in the 200–299 range and false otherwise. Checking it (and throwing your own error when it’s false) is what lets your catch block handle a 404 the same way it handles a dead connection. Leave this check out and your code will happily try to .json() an error page and break in a much more confusing place.
Don't forget the response.ok check
This is the number-one fetch gotcha. A try/catch around a fetch will not catch a 404 or 500 on its own — those are “successful” responses to fetch. Without if (!response.ok) your code marches on as if everything’s fine, then crashes later trying to read data that isn’t there. Make the response.ok check a reflex in every fetch you write.
Sending data: POST requests
So far we’ve only read data (a GET request, which is fetch’s default). To send data — create an account, submit a form, save a record — you make a POST request and pass a second argument to fetch with the details:
async function createUser(user) {
const response = await fetch("https://api.example.com/users", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(user),
});
if (!response.ok) {
throw new Error(`Failed to create user: ${response.status}`);
}
return await response.json();
}
const newUser = await createUser({ name: "John Doe", role: "editor" });
console.log(newUser);
Three things make a POST work, and each matters:
method: "POST"tells the server you’re sending data, not just asking for it.headerswith"Content-Type": "application/json"tells the server how to interpret the body. Without it, many servers won’t parse your JSON correctly.body: JSON.stringify(user)— and this is the easy one to forget — the body must be a string, not a JavaScript object.JSON.stringifyturns your object into JSON text. Hand fetch a raw object and it won’t send what you expect.
The same shape covers PUT (replace a record), PATCH (update part of one), and DELETE — just change the method. The Fetch API is one consistent pattern across every kind of request, which is a big part of why it’s pleasant to use.
Stringify going out, parse coming in
There’s a tidy symmetry worth remembering. When you send data, you JSON.stringify() your object into a string for the body. When you receive data, response.json() parses the string back into an object for you. Out: object → string. In: string → object. If you’ve read the JSON article, this is exactly JSON.stringify and JSON.parse doing their jobs — fetch just wires them into the network. A JSON formatter is handy when you want to eyeball a messy response by hand.
A complete, realistic example
Let’s tie it all together into something you might actually ship — load a list of products, render them, and handle the case where it fails:
async function loadProducts() {
try {
const response = await fetch("https://api.example.com/products");
if (!response.ok) {
throw new Error(`Server responded ${response.status}`);
}
const products = await response.json();
products.forEach((product) => {
console.log(`${product.name} — Rp ${product.price}`);
});
return products;
} catch (error) {
console.error("Failed to load products:", error.message);
showErrorMessage("Couldn't load products. Please try again.");
return [];
}
}
loadProducts();
Notice how naturally this builds on things you already know. The response is an array of objects — the most common shape real data arrives in — and we walk it with a loop (forEach) to read each product’s properties. Fetch didn’t replace anything you learned; it just delivers the data, and from there it’s plain JavaScript. The try/catch plus the return [] fallback means a network hiccup degrades gracefully instead of crashing the page.
Setting a request header for auth
Many real APIs require you to identify yourself with a token. You add it as a header, the same way you set Content-Type:
const response = await fetch("https://api.example.com/profile", {
headers: {
"Authorization": "Bearer YOUR_TOKEN_HERE",
"Accept": "application/json",
},
});
The Authorization header is the standard place a token goes. The exact format (Bearer ..., an API key, etc.) is decided by whatever API you’re calling — always check its documentation. Don’t hardcode real secret tokens into front-end code that ships to the browser, though; anyone can read them there. That’s a job for a backend.
Wrapping up
You can now talk to the network from JavaScript. The essentials:
fetch(url)sends an HTTP request and returns a promise for aResponseobject. Use it withasync/awaitfor readable code.- It takes two awaits:
await fetch(...)for the response, thenawait response.json()(or.text(),.blob()) to read the body. You can read the body only once. - fetch does not reject on 404/500. Always check
if (!response.ok)and throw, so yourtry/catchactually catches HTTP errors — not just network failures. - To send data, pass a second argument:
method,headers(withContent-Type), andbody: JSON.stringify(...). The same shape covers POST, PUT, PATCH, and DELETE. - Out: object →
JSON.stringify→ string. In: string →response.json()→ object.
Fetch is the doorway between your JavaScript and the rest of the internet. Combine it with the arrays, objects, and loops you already know, wrap every request in proper error handling, and you can build apps that pull live data and stay standing when the network has a bad day.