APIer/02 · Your first API call
CHAPTER 02 · fetch, async/await, DevTools

Your first API call

Chapter 01 described what an HTTP request looks like. This chapter sends one. About ten lines of JavaScript are enough to bring real data from a public server into your page. One step in the middle catches almost every beginner, so this chapter covers it early.

§01

fetch and the Promise it returns

fetch does not give you the data. It gives you an object that stands for data which has not arrived yet.

A ticket at the counter

You order a drink at a counter. The staff do not make you stand there and watch. They hand you a numbered ticket, you do something else, and you come back when your number is called.

fetch works the same way. One round trip over the network often takes hundreds of milliseconds, and JavaScript runs on a single thread, so it cannot stop and wait. The moment fetch(url) runs, it returns an object that stands for a result which has not arrived yet and which will later either succeed or fail. That object is called a Promise. When the response arrives, the Promise settles: it either resolves with a value or rejects with an error.

There are two ways to get the value out of a Promise, and they do the same thing. On the left is the older .then chain. On the right is async / await, which is what most code uses now.

then.js
1// Style 1: a chain of .then calls
2fetch("https://pokeapi.co/api/v2/pokemon/pikachu")
3 .then((res) => res.json())
4 .then((data) => {
5 console.log(data.name, data.weight);
6 });
await.js
1// Style 2: async / await
2async function show() {
3 const url =
4 "https://pokeapi.co/api/v2/pokemon/pikachu";
5 const res = await fetch(url);
6 const data = await res.json();
7 console.log(data.name, data.weight);
8}
await pauses the surrounding async function until the Promise settles, then gives you its value. It does not block the browser: other code, clicks, and animations keep running while that function is paused. async/await is a shorter way to write Promise code, not a way to make it synchronous. The rest of this book uses await.

Why two awaits?

The two awaits wait for two different things. The first one, await fetch(url), resolves as soon as the response headers have arrived. At that point you can read res.status and res.headers, but the body may still be downloading. The second one, await res.json(), reads the body and parses it into a JavaScript value. Reading the body is a separate asynchronous step, so res.json() returns a Promise of its own.

Three notes. ① await normally has to be inside an async function; ES modules and the browser console also allow it at the top level, which is why you can type it straight into the console for the practice tasks. ② An async function always returns a Promise, whatever value you return inside it. ③ So whoever calls it usually has to await it too. async spreads outward along the call chain. That is expected, not a mistake.

§02

The main trap: fetch does not reject on 404

This is the mistake almost everyone makes on their first API call. It is easier to learn it now than to debug it later.

'Success' means something narrower than you expect

You might expect 404 and 500 to jump into catch. They do not. For fetch, the call succeeded if the request reached the server and a response came back — even when that response says 404. The Promise from fetch rejects only when the request fails at the network level: no connection, a hostname that does not resolve, a request that was aborted, or a response the browser blocked under the CORS rules.

So you have to check the status yourself. res.ok is true when the status code is between 200 and 299. Skip that check and the body of a 404 response gets passed along as if it were normal data. The program then fails much later, far from the real cause, usually with a string of undefined.

The standard error-handling shape
1async function getJson(url) {
2 try {
3 const res = await fetch(url);
4 if (!res.ok) {
5 // 404 and 500 arrive here. Raise the status into an error.
6 throw new Error("HTTP " + res.status);
7 }
8 return await res.json();
9 } catch (err) {
10 // Network failures and the error thrown above both land here.
11 console.error("Request failed:", err.message);
12 throw err;
13 }
14}
The three highlighted lines are what makes this work. A bad HTTP status is not an error by itself, so you raise it into one. After that, every kind of failure ends up in the same catch. The shape try → if (!res.ok) throw → catch is used by every fetch example in this book.

res.json() can fail on its own

res.json() reads the response body and parses it as JSON. If the body is not valid JSON, the Promise it returns rejects with a SyntaxError. This happens more often than you would expect. A misconfigured server can answer with an HTML error page and still send status 200, so res.ok is true and the parse fails. An empty body does the same: a 204 No Content response has no body, so calling res.json() on it fails.

Notice that the example above writes return await res.json(), not return res.json(). With the await, the parse happens inside the try block, so this error lands in the same catch as everything else. Without it, the Promise would leave the function before try could see it fail.

Three outcomes, run each one yourself
Press a button. Your browser really sends that request, and the code below highlights the lines this run went through. Try all three.
§03

From data to page: a Pokemon lookup

JSON in a variable is only half the job. What a user sees is the page, so the last step is putting the data into the DOM.

A small but complete project: type a name, request it from PokeAPI (a free Pokemon database that needs no account), and show the height, the weight, and a picture. Two files.

index.html
1<input id="name" placeholder="Pokemon name, for example pikachu" />
2<button id="go">Search</button>
3<div id="result"></div>
4<script src="app.js"></script>
Three elements: an input, a button, and an empty container for the result. Each one has an id, which is how the JavaScript finds it.
app.js
1const $ = (id) => document.getElementById(id);
2
3$("go").addEventListener("click", async () => {
4 const name = $("name").value.trim().toLowerCase();
5 $("result").textContent = "Loading...";
6
7 try {
8 const res = await fetch("https://pokeapi.co/api/v2/pokemon/" + name);
9 if (!res.ok) throw new Error("HTTP " + res.status);
10 const p = await res.json();
11
12 $("result").innerHTML =
13 '<img src="' + p.sprites.front_default + '" alt="' + p.name + '">' +
14 "<p>Height " + p.height / 10 + " m · Weight " + p.weight / 10 + " kg</p>";
15 } catch (err) {
16 $("result").textContent = "Request failed: " + err.message;
17 }
18});
The highlighted lines are the ones that change the page. Before the request, the container says Loading... so the user knows something is happening. When the data arrives, fields from the JSON are written into the container. When it fails, the message says so. fetch → res.json() → update the DOM is the whole loop. One caution: innerHTML runs whatever HTML it is given, so use textContent for values you only want to show as text.

The widget below runs the same logic for real. Try it.

Live demo · this really calls PokeAPI
Type a name and press Search, or use one of the buttons above. The browser really sends a GET request to pokeapi.co.
§04

Sending data: the three parts of a POST

Every request so far has asked for data. Registering, posting, and ordering send data instead.

post.js
1const newPost = { title: "My first post", body: "hello api", userId: 1 };
2
3const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
4 method: "POST", // ① change the verb
5 headers: { "Content-Type": "application/json" }, // ② declare the body format
6 body: JSON.stringify(newPost), // ③ object -> JSON text
7});
8
9console.log(res.status); // 201
10const created = await res.json();
11console.log(created.id); // 101, the id the server assigned
The second argument to fetch is an options object. The three highlighted lines are the three parts of a POST: change the method, declare the format of the body, and turn the object into text. The 201 and the new id that come back are the standard reply to a successful creation, described in chapter 01.

Leave one part out and it breaks

Without JSON.stringify, fetch converts the object to a string the ordinary JavaScript way, and the server receives the text [object Object]. Not one field can be read out of it. Without Content-Type, the server may try to parse your JSON as plain text or as form data; the fields come out empty, or the server answers 400. Without method, the request is a GET, and a GET is not allowed to carry a body, so fetch throws a TypeError before anything is sent. The wording differs between browsers; Chrome says Request with GET/HEAD method cannot have body. That one is easy to notice, because it lands in catch immediately. The first two are much quieter.

When a server says it received nothing, check these three first. Most of the time one of them is missing.

§05

The Network panel in DevTools

When the code does not explain the problem, the messages do. Press F12, open Network, and every request is recorded there. Click through the tabs.

The metadata of this exchange

Three groups: General (the full URL, the request method, and the status code), Response Headers (what the server sent back), and Request Headers (what your browser sent). Chapter 01 explains what these headers mean.

Checking whether Content-Type is right, whether a caching header was sent, or whether Authorization was included: this tab is always the first place to look.

A response body can only be read once

res.json() reads the body as a stream: the data passes through once and is then gone. Calling res.json() a second time on the same response throws TypeError: body stream already read. If you need the data twice, store it the first time: const data = await res.json(). If you really need to read the raw body twice, res.clone() gives you a second copy, but you have to call it before either copy is read. DevTools can show you the body again because the browser kept its own copy, not because your code gets a second chance.

§06

A first look at CORS: a browser rule, not a failure

You will meet it sooner or later. It helps to recognize it in advance.

One day you call a new API. curl works. Postman works. But fetch in the browser prints a long red message containing the words CORS policy. The first reaction is usually "the API is down". It is not. The API answered. Your own browser refused to let your script read the answer.

The same-origin policy in one paragraph

Browsers follow a rule called the same-origin policy: a script loaded from one origin may not read a response from a different origin, unless that response says it is allowed. The response says so with a header, Access-Control-Allow-Origin. The set of rules around that header is called CORS (Cross-Origin Resource Sharing).

Two things follow from this. First, the browser enforces the rule, so it protects the user, not the server: curl, Postman, and any program running on a server ignore CORS entirely, and the header keeps nobody out. Second, a CORS failure is not an HTTP error status. There is no CORS status code. The response may well have been 200; the browser simply did not hand it to your code, so the fetch Promise rejects and the explanation is printed in the console.

The public APIs used in this chapter — PokeAPI, JSONPlaceholder, and Open-Meteo — all send the header, so they work from a page. Who blocks what, how a server allows an origin, and what an OPTIONS preflight request is are covered in chapter 06, together with authentication.

§07

Practice

Three tasks, all using real APIs that are free and need no account. You can run every one of them in the browser console.

§08

Quiz

Eight questions, all about the traps in this chapter.

QUESTION 01 / 8

At the instant const p = fetch(url) runs, what is in the variable p?

QUESTION 02 / 8

When is res.ok true?

QUESTION 03 / 8

In which case does the Promise from fetch reject, so that execution goes straight to catch?

QUESTION 04 / 8

After const a = await res.json(); you write another line, const b = await res.json();. What happens on the second line?

QUESTION 05 / 8

You want to send a piece of JSON data to a server with fetch. Which of these are required? (Select all that apply.)

QUESTION 06 / 8

The method that turns a JavaScript object into JSON text is JSON.____ (9 letters).

QUESTION 07 / 8

fetch in the browser reports a CORS error for an API, but curl gets a normal response from the same address. Who stopped the data?

QUESTION 08 / 8

async function f() { return 42; }. You call f(). What do you get back?

What to take away from this chapter
  • fetch returns a Promise immediately: an object standing for a result that has not arrived. await pauses the surrounding async function until it settles, without blocking the browser. An async function always returns a Promise.
  • Two awaits, two steps. The first resolves when the response headers arrive. res.json() is a second asynchronous step that reads and parses the body, and it can fail on its own when the body is not valid JSON.
  • fetch rejects only on a network-level failure. 404 and 500 are responses that arrived normally, so you check res.ok yourself. Keep the shape try → if (!res.ok) throw → catch.
  • Getting data onto the page is three steps: fetch → res.json() → update the DOM. The body is a stream and can be read once, so store the result if you need it twice.
  • A POST needs three things: method, Content-Type: application/json, and JSON.stringify(body). Leave one out and the data does not arrive as JSON.
  • When descriptions do not match, open the Network panel: Headers for the metadata, Payload for what you sent, Response for what came back, Timing for where the time went.
  • A CORS error is the browser enforcing the same-origin policy. It is not an API failure, and it is not an HTTP status. Chapter 06 covers it.