RizTech Academy logo
RizTech Academy
Asynchronous JavaScriptLesson 5 of 730 min

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

  1. Fetch a local JSON file and log its length and first item. Then remove one of the two awaits and identify the symptom.
  2. Read response.json() twice and read the error.
  3. Prove the 404 trap. Fetch a URL that does not exist inside a try/catch and confirm the catch does not run. Print ok and status.
  4. Then call .json() on that 404 response and produce Unexpected token '<' yourself. This is the error you will see most often in your career — make it happen on purpose once.
  5. Add the response.ok check and throw a useful message instead.
  6. Fetch a host that does not exist and confirm fetch does reject, with Failed to fetch.
  7. Write getOrders() handling all three outcomes with different messages.
  8. POST an object to any endpoint, then send the object without JSON.stringify and look at what was actually transmitted in the Network panel.
  9. Build a URL with URLSearchParams including a value containing & and a space. Check the encoding in the Network panel.
  10. Add a 5-second timeout with AbortController, then test it against a deliberately slow endpoint. Handle AbortError separately.
  11. 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.
  12. Harder. Write fetchJson(url, options) that handles all three outcomes, applies a timeout, throws typed errors (NetworkError, HttpError with a status, 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