Mert Özen Further With Every Line
Frontend JavaScript Intermediate — #11

JavaScript Fetch API: GET, POST, response.ok, Timeouts and an Intro to CORS

Mert Özen Nov 3, 2026 9 min 6 views
JavaScript Fetch API: GET, POST, response.ok, Timeouts and an Intro to CORS

We fetch and send data with the Fetch API and see why a 404 skips catch, how response.ok helps, POST/PUT/DELETE, timeouts with AbortSignal.timeout, CORS, and showing loading and error states.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

We're asking for a resource that doesn't exist: there is no post number 9999. Which one gets printed to the console, then or catch?

fetch("https://jsonplaceholder.typicode.com/posts/9999")
  .then((response) => console.log("then:", response.status))
  .catch(() => console.log("catch"));

Make a guess before you keep reading. This question sits right at the heart of one of the most common mistakes in code written with fetch. We'll explain the answer once we've met the response object.

The First Request: GET and JSON

So far we've faked asynchronous work with setTimeout. Now we're going to talk to a real server, using the fetch function the browser provides. In the examples we use JSONPlaceholder, a service that serves fake data for testing and accepts requests coming from the browser.

Open the console on the https://jsonplaceholder.typicode.com page; some sites block requests to other addresses with their own security settings, but this page has no such problem. Then paste this and try it:

const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
const post = await response.json();

console.log(post.title);
console.log(post);

We didn't write a function here because the console allows top-level await. On a real page these lines would live inside an async function. If you get a "has already been declared" error while pasting the examples one after another, reload the page and carry on; you're trying to declare a const with the same name a second time.

The flow has two steps and both are asynchronous. fetch returns a promise, and that promise fulfills with a Response object as soon as the headers of the server's reply arrive. But the body, the actual data, may not have fully arrived yet. response.json() reads the body to the end, parses it as JSON and gives you that as a promise too. That's why there are two awaits.

Think of a parcel delivery. When the courier arrives, you first look at the label on the box: who sent it, is it damaged. That's the Response itself. Opening the box and taking out what's inside is a separate job; that's json().

The Response Object: status, ok and the Body

The Response object you get carries everything about the reply:

const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");

console.log(response.status);                       // 200
console.log(response.ok);                           // true
console.log(response.headers.get("content-type")); // "application/json; charset=utf-8"

status is the HTTP status code. Roughly, 2xx means success, 3xx redirection, 4xx a client error (404 not found, 401 unauthorized) and 5xx a server error. ok is a handy shortcut: true if the status code is between 200 and 299, false otherwise.

There are several methods for reading the body: json() for JSON, text() for plain text or HTML. They all return promises, and there's an important rule: the body can be read only once. If you call text() and then json(), the second one rejects with a TypeError. A parcel gets opened once.

Why Doesn't a 404 Land in catch?

The answer to the opening question is then: 404. Surprising, but true. The promise from fetch doesn't reject when the server returns an error code. The server said "that post doesn't exist", and that's a response too. The request went out, a response came back; as far as fetch is concerned, the job completed successfully.

fetch only rejects when the request itself fails: no internet connection, a malformed address, a CORS block, or a cancelled request. So checking error codes is your job. To avoid writing that check every time, it's a good habit to set up a small helper:

async function getJSON(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${url}`);
  }
  return response.json();
}

try {
  const post = await getJSON("https://jsonplaceholder.typicode.com/posts/9999");
  console.log(post);
} catch (error) {
  console.error(error.message); // "HTTP 404: https://jsonplaceholder.typicode.com/posts/9999"
}

Now network errors and HTTP errors both land in the same catch. We'll use getJSON for the rest of this post.

Sending Data: POST, PUT, DELETE and Headers

You can pass fetch an options object as its second argument. The default method is GET; to send data, you specify the method, the headers and the body:

const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    title: "Learning the Fetch API",
    body: "My first POST request",
    userId: 1,
  }),
});

console.log(response.status); // 201
console.log(await response.json());
// { title: "Learning the Fetch API", body: "My first POST request", userId: 1, id: 101 }

Notice three things. You can't pass the object directly; you have to turn it into a string with the JSON.stringify we saw earlier. The Content-Type header tells the server "I'm sending you JSON"; forget it and many servers won't understand the body. Finally, the 201 code means "created". Since JSONPlaceholder is a fake service it doesn't really save the data; it just answers as if it had.

Updating and deleting follow the same pattern:

// Replace the whole record
const updated = await fetch("https://jsonplaceholder.typicode.com/posts/1", {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ id: 1, title: "New title", body: "New content", userId: 1 }),
});
console.log(updated.status); // 200

// Delete the record
const deleted = await fetch("https://jsonplaceholder.typicode.com/posts/1", {
  method: "DELETE",
});
console.log(deleted.status); // 200

Headers carry extra information about the request. With Accept you say which format you expect back, with Authorization you say who you are. An important warning here: anyone can see code that runs in the browser. If you bake a secret API key into your front-end code, anyone who opens the page can find it in DevTools. We'll talk about where to keep things like a user's session token in the post on Web Storage and cookies.

Cancelling and Timeouts: AbortController

If you fire a new request on every keystroke while the user types a search, you'll want to cancel the old ones. Or if a server isn't answering, you don't want to wait forever. The tool for both is the same: an AbortSignal.

const controller = new AbortController();

setTimeout(() => controller.abort(), 50); // give up after 50 ms

try {
  const response = await fetch("https://jsonplaceholder.typicode.com/photos", {
    signal: controller.signal,
  });
  console.log(response.status);
} catch (error) {
  console.log(error.name); // most likely "AbortError"
}

AbortController works like a remote control: you hand its signal to fetch, and when you press the abort() button the request is cut off and fetch's promise rejects with an error named AbortError. We said "most likely" because on a fast connection the response might arrive within 50 milliseconds.

If all you want is a timeout, there's a shorter way. AbortSignal.timeout() creates a signal that aborts by itself once the given time runs out, and it has worked in all current browsers since April 2024:

try {
  const response = await fetch("https://jsonplaceholder.typicode.com/photos", {
    signal: AbortSignal.timeout(5000),
  });
  const photos = await response.json();
  console.log(photos.length); // 5000
} catch (error) {
  if (error.name === "TimeoutError") {
    console.error("The server didn't answer within 5 seconds");
  } else if (error.name === "AbortError") {
    console.error("The request was cancelled");
  } else {
    console.error("Network error:", error.message);
  }
}

Notice that on a timeout the error's name is not AbortError but TimeoutError. That distinction comes in handy: you can show the user different messages for "the operation was cancelled" and "the server is slow, try again".

An Introduction to CORS

When a web page sends a request to an address other than its own, that is, other than its own origin, the browser applies a security rule: the same-origin policy. By default, it doesn't let JavaScript read a response coming from another origin. Otherwise any site you opened could quietly send a request to your bank's site in the background and read your account details while you're logged in.

CORS (Cross-Origin Resource Sharing) lets the server make a controlled exception to that rule. By adding headers such as Access-Control-Allow-Origin to its response, the server says "pages from these origins may read me." JSONPlaceholder sends those headers, which is why we can use it from any page.

There are two things to keep in mind. First, CORS is a rule enforced by the browser and the permission comes from the server; nothing you write in front-end code can get around it. Second, a fetch blocked by CORS rejects with a TypeError that gives no details. Your code can't see the real reason; you only read it in the red warning in the console. When you're wondering "why does my fetch keep failing?", look at the console first.

And a historical note: before fetch, this was done with XMLHttpRequest. You'll still run into it in older code, but it's callback-based and clumsy to use. Use fetch in new code.

Showing Loading and Error States

Working in the console is great for learning, but users don't see the console. A request has three states: loading, success and error. A good interface shows all three. Save the file below and open it in your browser:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Post list with fetch</title>
</head>
<body>
  <label>User id: <input id="userId" type="number" value="1"></label>
  <button id="load">Load posts</button>
  <p id="status"></p>
  <ul id="list"></ul>

  <script>
    const button = document.querySelector("#load");
    const statusEl = document.querySelector("#status");
    const list = document.querySelector("#list");

    async function getJSON(url) {
      const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
      if (!response.ok) {
        throw new Error(`The server returned ${response.status}`);
      }
      return response.json();
    }

    button.addEventListener("click", async () => {
      const userId = document.querySelector("#userId").value;
      button.disabled = true;
      statusEl.textContent = "Loading...";
      list.replaceChildren();

      try {
        const posts = await getJSON(
          `https://jsonplaceholder.typicode.com/posts?userId=${userId}`
        );
        if (posts.length === 0) {
          statusEl.textContent = "This user has no posts.";
          return;
        }
        for (const post of posts) {
          const li = document.createElement("li");
          li.textContent = post.title;
          list.append(li);
        }
        statusEl.textContent = `Found ${posts.length} posts.`;
      } catch (error) {
        statusEl.textContent = error.name === "TimeoutError"
          ? "The server is too slow, please try again."
          : `Something went wrong: ${error.message}`;
      } finally {
        button.disabled = false;
      }
    });
  </script>
</body>
</html>

We disable the button while the request is running so the user can't click repeatedly and fire ten requests. We write the titles with textContent instead of innerHTML; never trust data from a server as HTML, and the XSS risk we discussed in the Basics series applies here too. And finally re-enables the button whatever happens. Try turning off your internet connection too, or enter 999 as the id and see the empty-list message.

Common Pitfalls

The first, as we saw in the opening question, is not checking response.ok. You treat a 404 or 500 response as success and try to process its body; more often than not you end up with a confusing error message, because the server's error page isn't the JSON you were expecting.

The second is forgetting to await json(). Write const data = response.json() and you're holding a promise, not the data.

The third is not converting the body with JSON.stringify on a POST, or leaving out the Content-Type header. Pass the object straight to body and the server receives the text "[object Object]".

The fourth is trying to fix a CORS error on the front end. The permission comes from the server; either the server's configuration has to change, or the request has to go through your own server.

A Small Experiment

★☆☆ Fetch the user from https://jsonplaceholder.typicode.com/users/1 and print their name, email and city (address.city) to the console.

★★☆ Predict the output, then try it. Which lines are printed, and in what order?

try {
  const response = await fetch("https://jsonplaceholder.typicode.com/nope");
  console.log("A", response.ok);
  const data = await response.json();
  console.log("B", data);
} catch (error) {
  console.log("C", error.name);
}

★★★ Add a "Cancel" button to the page example. When a new request starts, create an AbortController; the cancel button should call its abort(), and when the request is cancelled the page should say "Request cancelled". Hint: you can combine two signals with AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]).

In this post we touched the DOM with a few lines while putting server data on the page. As lists grow longer and cards get more complex, we need tidier and faster ways to do that. In the next post we'll look at navigating the DOM tree, dataset, the template element and updating the page in batches without wearing it out.