Fetching data from an API
Everything so far has been timers pretending to be slow work. fetch is the
real thing: asking a server for data and getting it back over a network that is
slower and less reliable than anything you have simulated.
The shape
const response = await fetch('/orders.json');
const orders = await response.json();
console.log(orders.length);
console.log(orders[0].customer);
3
Priya
Two awaits, and both are necessary. The first waits for the response headers to arrive; the second waits for the body to download and parse. On a large response over mobile data those are genuinely different moments.
response.json() returns a promise — forgetting its await gives you
Promise { <pending> } where you expected an array, which is the missing-await
symptom from the last lesson.
Other body readers: response.text() for plain text or HTML, response.blob()
for a file or image. You can only read the body once:
await response.json();
await response.json();
TypeError: Failed to execute 'json' on 'Response': body stream already read
Read it once into a variable.
The trap: a 404 is not an error
This is the single most important thing in the lesson.
const response = await fetch('/definitely-missing.json');
console.log('no error was thrown');
console.log(response.ok);
console.log(response.status);
no error was thrown
false
404
fetch does not reject on 404, or 500, or 403. As far as it is concerned,
asking the server and being told "no" is a successful request — it got an
answer.
So this is broken:
try {
const response = await fetch('/orders');
const orders = await response.json();
render(orders);
} catch (error) {
showError(error);
}
On a 404 the server usually returns an HTML error page, so response.json()
throws — and you get this, which is one of the most-searched errors in
JavaScript:
SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON
Unexpected token '<' almost always means you fetched an HTML error page and
tried to read it as JSON. The real problem was the 404; the JSON parser is just
the messenger.
Always check response.ok:
const response = await fetch('/orders');
if (!response.ok) {
throw new Error(`Could not load orders (${response.status})`);
}
const orders = await response.json();
response.ok is true for any status from 200 to 299.
What fetch does reject on
try {
await fetch('http://localhost:9/nope');
} catch (error) {
console.log(error.name, '-', error.message);
}
TypeError - Failed to fetch
Only genuine failures to get an answer: no network, DNS failure, connection refused, CORS blocked, request aborted.
Failed to fetch is deliberately vague — the browser will not tell you
which, because that would leak information about the network to a page. Check
devtools' Network panel, which does know.
So there are three outcomes to handle, not two:
| Outcome | How it shows |
|---|---|
| Worked | response.ok is true |
| Server said no | response.ok is false, response.status says why |
| Could not ask | fetch rejects with a TypeError |
A function that handles all three
async function getOrders() {
let response;
try {
response = await fetch('/api/orders');
} catch {
throw new Error('Could not reach the server. Check your connection.');
}
if (!response.ok) {
throw new Error(`Could not load orders (${response.status})`);
}
return response.json();
}
Two different messages, because they need two different actions from the reader. "Check your connection" is useless advice when the server returned a 500, and "something went wrong" is useless in both cases.
Sending data
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer: 'Priya', plates: 3 }),
});
Three things that must all be right:
method defaults to GET.
body must be a string — JSON.stringify it. Passing the object directly
sends [object Object], module 7's storage bug in a new place.
Content-Type tells the server how to read it. Without it many servers will
not parse the body, and you get a confusing 400.
For a file upload, pass a FormData object as the body and do not set
Content-Type — the browser sets it, with a boundary marker it has to
generate.
| Option | For |
|---|---|
method |
'GET', 'POST', 'PUT', 'PATCH', 'DELETE' |
headers |
Content type, authorisation |
body |
A string, FormData, or a Blob |
signal |
An AbortController signal |
credentials |
'include' to send cookies cross-origin |
Query strings, safely
Do not build a URL by joining strings — a customer name with a & in it will
break it:
const url = new URL('/api/orders', location.origin);
url.searchParams.set('area', 'Kharadi');
url.searchParams.set('q', 'toor dal & sugar');
const response = await fetch(url);
URLSearchParams encodes everything for you.
Timeouts and cancelling
fetch has no timeout. On a bad connection it can hang indefinitely, which
is exactly the case you need to handle for a user on a train.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch('/api/orders', { signal: controller.signal });
return await response.json();
} finally {
clearTimeout(timer);
}
An aborted fetch rejects with an AbortError:
catch (error) {
if (error.name === 'AbortError') {
throw new Error('The server took too long.');
}
throw error;
}
The other use is cancelling work you no longer need — a search box where the user has typed again. Abort the previous request and the out-of-date response cannot arrive late and overwrite the new one. That race is a real bug, and it looks like a search box that occasionally shows results for what you typed three letters ago.
CORS, in one paragraph
Fetch another site's API and you may see:
Access to fetch at 'https://example.com/api' from origin 'http://localhost:3000'
has been blocked by CORS policy
A browser will not let a page read a response from another origin unless that
server says it may, via a Access-Control-Allow-Origin header. It is a
security rule protecting the user, not a bug in your code, and you cannot fix
it from the front end. The fix belongs on the server, or in a proxy you
control. Note the request usually was sent — the browser blocks you reading the
reply.
And still: never trust it
Module 1's rule, at its most concrete. A response is data from somewhere else.
It may be missing fields, contain null where you expected a number, or be an
HTML error page.
Use module 4's ?. and ??, and never put it in the page with innerHTML —
module 7's XSS lesson. Data from an API is exactly as untrusted as data from a
form.
Check your work
Two awaits: one for the response, one for response.json().
A body can only be read once — a second .json() throws body stream already read.
fetch does not reject on 404 or 500. response.ok is false and
response.status says which. Check ok before reading the body.
Unexpected token '<', "<!doctype "... is not valid JSON means you parsed an
HTML error page as JSON — the real problem is the status you did not check.
fetch rejects only when it could not get an answer, with
TypeError: Failed to fetch, deliberately vague.
Three outcomes, not two: worked, server said no, could not ask.
A POST body must be a string, with Content-Type: application/json. For
FormData, let the browser set the header.
fetch has no timeout. Use AbortController with a setTimeout; an abort
rejects with AbortError.
CORS is enforced by the browser and fixed on the server. You cannot work around it from the page.
Practice
- Fetch a local JSON file and log its length and first item. Then remove one of
the two
awaits and identify the symptom. - Read
response.json()twice and read the error. - Prove the 404 trap. Fetch a URL that does not exist inside a
try/catchand confirm thecatchdoes not run. Printokandstatus. - Then call
.json()on that 404 response and produceUnexpected token '<'yourself. This is the error you will see most often in your career — make it happen on purpose once. - Add the
response.okcheck and throw a useful message instead. - Fetch a host that does not exist and confirm
fetchdoes reject, withFailed to fetch. - Write
getOrders()handling all three outcomes with different messages. - POST an object to any endpoint, then send the object without
JSON.stringifyand look at what was actually transmitted in the Network panel. - Build a URL with
URLSearchParamsincluding a value containing&and a space. Check the encoding in the Network panel. - Add a 5-second timeout with
AbortController, then test it against a deliberately slow endpoint. HandleAbortErrorseparately. - Open the Network panel and watch your requests: status, timing, response body. Then throttle to Slow 3G and reload — that is your user in India on mobile data, and it should change how you feel about a missing loading state.
- Harder. Write
fetchJson(url, options)that handles all three outcomes, applies a timeout, throws typed errors (NetworkError,HttpErrorwith astatus,TimeoutError) using module 5's custom error classes, and returns parsed JSON on success. Then write a caller that shows a different message for each. Every lesson in this module is in that one function.
Next: handling errors in async code properly — where to catch, what to show, and what to do while waiting.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship