Skip to content
elephantoo

fetch, APIs & JSON

Lesson 29 of 34 18 min read

Call REST APIs with fetch, send and parse JSON, handle HTTP errors, timeouts and CORS.


Almost every app talks to a server: loading products, saving a form, logging in, getting the weather. The built-in fetch function sends HTTP requests from the browser and from Node.js (18+), and JSON is the format most APIs use for data. Combined with async/await, this is the bread and butter of front-end work.

HTTP in 60 seconds#

A request has a method, a URL, optional headers and an optional body. The response has a status code, headers and a body.

MethodMeaningExample
GETRead dataGET /api/courses
POSTCreate somethingPOST /api/courses with a body
PUT / PATCHReplace / partly updatePATCH /api/courses/7
DELETERemoveDELETE /api/courses/7
StatusMeaning
200 OK, 201 Created, 204 No ContentSuccess
301/302/304Redirects / not modified
400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 422 validation failed, 429 Too Many RequestsClient errors
500 Internal Server Error, 503 Service UnavailableServer errors

The examples below use JSONPlaceholder (jsonplaceholder.typicode.com), a free fake REST API for practice. Run them in the browser console or with Node as .mjs files.

Your first GET request#

JavaScript
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
console.log(response.status, response.ok); // 200 true
console.log(response.headers.get("content-type")); // application/json; charset=utf-8

const todo = await response.json();
console.log(todo);
Output
200 true
application/json; charset=utf-8
{ userId: 1, id: 1, title: 'delectus aut autem', completed: false }

Two steps, two awaits:

  1. fetch(url) resolves as soon as the headers arrive, giving a Response object.
  2. response.json() reads the body and parses it as JSON (also async). Other readers: .text(), .blob(), .formData(), .arrayBuffer(). You can only read the body once.

Checking for HTTP errors#

This surprises everyone: fetch doesn't reject on 404 or 500. It only rejects if the request couldn't be made at all (offline, DNS failure, CORS block, abort). Always check response.ok:

JavaScript
const res = await fetch("https://jsonplaceholder.typicode.com/todos/999999");
console.log(res.ok, res.status); // false 404 – but no error was thrown!

So wrap fetch in a helper that throws for bad statuses:

JavaScript
class HttpError extends Error {
  constructor(response, body) {
    super(`HTTP ${response.status} ${response.statusText} for ${response.url}`);
    this.name = "HttpError";
    this.status = response.status;
    this.body = body;
  }
}

async function getJSON(url, options = {}) {
  const response = await fetch(url, {
    ...options,
    headers: { Accept: "application/json", ...options.headers },
  });
  const isJson = response.headers.get("content-type")?.includes("application/json");
  const body = isJson ? await response.json() : await response.text();
  if (!response.ok) throw new HttpError(response, body);
  return body;
}

try {
  const user = await getJSON("https://jsonplaceholder.typicode.com/users/3");
  console.log(user.name, "–", user.email);
  await getJSON("https://jsonplaceholder.typicode.com/users/0");
} catch (err) {
  if (err instanceof HttpError && err.status === 404) {
    console.log("Not found:", err.message);
  } else {
    console.log("Network or parsing problem:", err.message);
  }
}
Output
Clementine Bauch – Nathan@yesenia.net
Not found: HTTP 404 Not Found for https://jsonplaceholder.typicode.com/users/0

Query parameters#

Build URLs with URL and URLSearchParams instead of string concatenation — they handle encoding for you:

JavaScript
const url = new URL("https://jsonplaceholder.typicode.com/posts");
url.searchParams.set("userId", "1");
url.searchParams.set("_limit", "3");
console.log(url.toString()); // https://jsonplaceholder.typicode.com/posts?userId=1&_limit=3

const posts = await (await fetch(url)).json();
console.log(posts.map((p) => p.id)); // [ 1, 2, 3 ]

const params = new URLSearchParams({ q: "café & cake", page: 2 });
console.log(params.toString()); // q=caf%C3%A9+%26+cake&page=2

Sending data: POST, PATCH, DELETE#

JavaScript
const newPost = { title: "Learning fetch", body: "It's great!", userId: 1 };

const created = await fetch("https://jsonplaceholder.typicode.com/posts", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(newPost),
});
console.log(created.status);       // 201
console.log(await created.json()); // { title: 'Learning fetch', body: "It's great!", userId: 1, id: 101 }

const patched = await fetch("https://jsonplaceholder.typicode.com/posts/1", {
  method: "PATCH",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ title: "Updated title" }),
});
console.log((await patched.json()).title); // Updated title

const deleted = await fetch("https://jsonplaceholder.typicode.com/posts/1", { method: "DELETE" });
console.log(deleted.ok); // true

(JSONPlaceholder fakes writes — nothing is really saved.)

For HTML forms and file uploads, pass a FormData object as the body and don't set Content-Type — the browser sets the correct multipart boundary for you:

JavaScript
const form = document.querySelector("#avatar-form");
await fetch("/api/avatar", { method: "POST", body: new FormData(form) });

JSON in depth#

JSON (JavaScript Object Notation) is text that looks like JavaScript literals, with stricter rules: keys and strings in double quotes, no trailing commas, no comments, no functions, no undefined.

JavaScript
const course = {
  title: "JavaScript",
  lessons: 34,
  tags: ["web", "frontend"],
  publishedAt: new Date("2026-01-01T00:00:00Z"),
  draft: undefined,
  print() {},
};

const json = JSON.stringify(course);
console.log(json);
// {"title":"JavaScript","lessons":34,"tags":["web","frontend"],"publishedAt":"2026-01-01T00:00:00.000Z"}

const back = JSON.parse(json);
console.log(typeof back.publishedAt); // "string" – dates don't come back as Date objects

console.log(JSON.stringify({ a: 1, b: [1, 2] }, null, 2)); // pretty-printed with 2 spaces

Notice what happened: the Date became an ISO string, while undefined and functions were dropped. A reviver function can restore dates when parsing:

JavaScript
const revived = JSON.parse(json, (key, value) =>
  key.endsWith("At") ? new Date(value) : value
);
console.log(revived.publishedAt.getUTCFullYear()); // 2026

JSON.parse throws a SyntaxError on invalid input — wrap untrusted JSON in try/catch.

Timeouts and cancellation#

fetch has no default timeout. Use AbortSignal.timeout:

JavaScript
try {
  const res = await fetch("https://jsonplaceholder.typicode.com/photos", {
    signal: AbortSignal.timeout(5000), // give up after 5 s
  });
  console.log((await res.json()).length); // 5000
} catch (err) {
  if (err.name === "TimeoutError") console.log("The request took too long");
  else throw err;
}

To cancel manually — e.g. when the user types a new search before the old one returns — use an AbortController:

JavaScript
let controller;

async function search(term) {
  controller?.abort(); // cancel the previous request
  controller = new AbortController();
  try {
    const url = `https://jsonplaceholder.typicode.com/posts?q=${encodeURIComponent(term)}`;
    const res = await fetch(url, { signal: controller.signal });
    return await res.json();
  } catch (err) {
    if (err.name === "AbortError") return null; // superseded – ignore
    throw err;
  }
}

Putting it on the page#

HTML
<button id="load">Load posts</button>
<p id="status" role="status"></p>
<ul id="posts"></ul>
JavaScript
const button = document.querySelector("#load");
const status = document.querySelector("#status");
const list = document.querySelector("#posts");

button.addEventListener("click", async () => {
  button.disabled = true;
  status.textContent = "Loading…";
  try {
    const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5");
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const posts = await res.json();
    list.replaceChildren(
      ...posts.map((post) => {
        const li = document.createElement("li");
        li.textContent = post.title; // textContent: safe even if the API sends HTML
        return li;
      })
    );
    status.textContent = `Loaded ${posts.length} posts`;
  } catch (err) {
    status.textContent = `Could not load posts: ${err.message}`;
  } finally {
    button.disabled = false;
  }
});

Every real UI needs these three states: loading, success and error.

CORS: why requests get blocked#

Browsers apply the same-origin policy: a page on https://myapp.com can't read responses from https://api.other.com unless that server opts in with headers like Access-Control-Allow-Origin: https://myapp.com. If it doesn't, you'll see a CORS error in the console.

  • CORS is enforced by the browser; the same request from Node or curl works fine.
  • The fix is on the server (configure CORS headers), or call the third-party API through your own backend, or use a dev-server proxy (Vite's server.proxy) during development.
  • mode: "no-cors" doesn't fix anything — it gives you an empty, unreadable response.

Authentication and secrets#

JavaScript
const token = "your-access-token"; // obtained after the user logs in
await fetch("https://api.example.com/me", {
  headers: { Authorization: `Bearer ${token}` },
});

// Cookies: same-origin requests send them automatically; for cross-origin:
await fetch("https://api.example.com/me", { credentials: "include" });

Never put secret API keys in front-end code — anyone can read them in DevTools. Keep secrets on a server and have it call the third-party API.

Common mistakes#

  • Not checking response.ok and treating a 404 error page as data.
  • Forgetting await on response.json().
  • Sending an object as body without JSON.stringify, or forgetting the Content-Type header.
  • Reading the body twice (await res.json() then await res.text()) — the second throws.
  • Inserting API data with innerHTML (XSS risk).
  • Trying to fix CORS in the browser.

What's next#

You've completed asynchronous JavaScript! In the Advanced module you'll start with iterators and generators — the protocol behind for...of, spread and async streams.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Does fetch reject its promise when the server responds with HTTP 404 or 500?

  2. 2.When POSTing JSON with fetch, what should the body be?

  3. 3.A page on https://myapp.com fetches https://api.other.com/data and gets a CORS error. Where must the fix happen?

Finished reading?

Mark this lesson complete to track your progress.