Network Requests

Fetch API & AJAX

Every modern web app talks to a server. The Fetch API is the standard way to send requests, read responses and update the page without a full reload.

intermediate15 min readUpdated Sep 15, 2026
js
// api.js
async function getUsers() {
  const res = await fetch("/api/users", {
    headers: { Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

const users = await getUsers();
console.log(users);
Standard
Part of the Fetch Living Standard
Returns
A Promise<Response>
Replaces
XMLHttpRequest
Read body as
json, text, blob, formData
Cancel with
AbortController
Rejects on
Network errors only

Hands-on

Try a fetch request

This demo fetches a JSON data URL, so it works offline.

Try a fetch request

This demo fetches a JSON data URL, so it works offline.

Why it matters

Why fetch matters

One API for everything

The same fetch call handles JSON APIs, text, files, streams and form data across browsers and runtimes.

Promise-based

Fetch returns a promise, so it composes perfectly with async/await and Promise combinators.

Secure by default

Sensible defaults around CORS and credentials help you avoid accidental data leaks.

The big picture

The three parts of every request

A network request is a request, a response and the error handling around them. Get those three right and the rest is detail.

The request

Send

The URL, method, headers and body describe exactly what you want the server to do.

The response

Read

Status, headers and a body you parse as JSON, text, a blob or a stream.

The errors

Recover

Distinguish network failures from HTTP error statuses, then show the user something useful.

Fetch at a glance

What the Fetch API gives you

GET requests

Fetch data from a URL and read the response body.

POST & PUT

Send data to the server with a method, headers and a body.

Reading JSON

res.json() parses the response body into a JavaScript value.

Error handling

Check res.ok for HTTP errors and catch for network failures.

Cancelling

AbortController stops requests that are no longer needed.

CORS & credentials

Understand origins, preflight requests and when cookies are sent.

A short history

From XMLHttpRequest to fetch

  1. 1999

    XMLHttpRequest

    Microsoft introduces XHR, and the AJAX era begins.

    99
  2. 2005

    AJAX goes mainstream

    Gmail and Google Maps prove pages can update without reloading.

    05
  3. 2015

    The Fetch API

    A cleaner, promise-based replacement for XHR ships in browsers.

    15
  4. 2017

    async / await

    Fetch becomes dramatically more readable with the new syntax.

    17
  5. Today

    The default

    Fetch is the standard request API in browsers, Node.js, Deno and edge runtimes.

    Today

The complete guide

Fetch API & AJAX: Everything you need to know

What is AJAX?

AJAX stands for Asynchronous JavaScript and XML, a name coined in 2005 when Gmail and Google Maps first showed that a page could fetch new data without a full reload. The XML part is mostly history — modern APIs return JSON — but the core idea remains: talk to a server in the background and update the page in place.

The original tool was XMLHttpRequest. It works, but its API is awkward and callback-based. The modern replacement is the Fetch API, a promise-based interface available in browsers, Node.js, Deno and edge runtimes.

Your first fetch request

fetch takes a URL and returns a promise for a Response.

// basic.js
const response = await fetch("https://api.example.com/users");
const users = await response.json();

console.log(users);

That is the whole happy path. The subtlety is that fetch resolves even for error status codes, so a 404 does not throw. You have to inspect the response yourself.

The Response object

The Response object tells you what the server sent back.

// response.js
const response = await fetch("/api/users");

response.ok;      // true for status 200–299
response.status;  // 200, 404, 500, ...
response.statusText;
response.headers.get("content-type");

const data = await response.json();   // parse JSON
const text = await response.text();   // raw text
const blob = await response.blob();   // binary data
const form = await response.formData();

A body can only be read once. If you need both the raw text and the parsed JSON, call response.clone() before reading, or parse the text yourself. Also note that response.json() rejects if the body is not valid JSON — another reason to wrap calls in try/catch.

Handling errors properly

There are two kinds of failure, and they behave differently.

  1. Network errors — no connection, DNS failure, blocked CORS. fetch rejects, so catch handles them.
  2. HTTP errors — 404, 401, 500. fetch resolves; you must check response.ok or response.status.
// errors.js
async function getUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error("Could not load user:", error);
    throw error;
  }
}

Treating both cases explicitly is what separates fragile code from resilient code. The Error Handling guide covers the broader strategy.

Sending data with POST, PUT and DELETE

Pass an options object to change the method, add headers and attach a body.

// create.js
async function createUser(user) {
  const response = await fetch("/api/users", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(user),
  });

  if (!response.ok) throw new Error("Could not create user");
  return response.json();
}

await createUser({ name: "Ada", role: "engineer" });

Use PUT to replace a resource, PATCH to update part of it, and DELETE to remove it. For file uploads, build a FormData object and pass it as the body — the browser sets the correct multipart content type for you.

Headers and authentication

Headers carry metadata about the request. You can set them per request, and many APIs expect an authorization token.

// auth.js
const response = await fetch("/api/me", {
  headers: {
    Authorization: `Bearer ${token}`,
    Accept: "application/json",
  },
});

Never hard-code secrets in client-side code — anything shipped to the browser is public. Tokens should come from a login flow and be stored with care.

Cancelling requests

A request that is no longer relevant — the user typed another character, navigated away or the component unmounted — should be cancelled. AbortController does exactly that.

// abort.js
const controller = new AbortController();

fetch("/api/search?q=javascript", { signal: controller.signal })
  .then((res) => res.json())
  .then(console.log)
  .catch((error) => {
    if (error.name === "AbortError") return;
    console.error(error);
  });

// Cancel when it is no longer needed
controller.abort();

Because fetch has no timeout option, AbortController is also the standard way to implement one.

// timeout.js
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

try {
  const res = await fetch("/api/slow", { signal: controller.signal });
  return await res.json();
} finally {
  clearTimeout(timer);
}

CORS and credentials

Browsers enforce the same-origin policy: JavaScript cannot read responses from a different origin unless the server opts in. That opt-in is CORS, and it is configured entirely on the server through response headers such as Access-Control-Allow-Origin. If you see a CORS error, the fix belongs in the server configuration, not in your fetch call.

By default, fetch does not send cookies to cross-origin URLs. To include them, set credentials: "include" and make sure the server allows credentialed requests. This is a common source of “it works in Postman but not the browser” confusion.

Practical patterns

Loading, success and error states. Always reflect the request lifecycle in the UI.

// state.js
async function loadPosts() {
  showSpinner();
  try {
    const res = await fetch("/api/posts");
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    renderPosts(await res.json());
  } catch (error) {
    showError("Could not load posts. Try again.");
  } finally {
    hideSpinner();
  }
}

Debounced search. Cancel the previous request whenever a new one starts, so slow responses cannot overwrite fresh results. Pair AbortController with a small debounce delay.

Parallel requests. Use Promise.all when several endpoints are needed at once, and Promise.allSettled when a partial result is still useful.

Best practices

  • Always check response.ok before parsing the body.
  • Set an explicit Accept header and a Content-Type when sending a body.
  • Wrap awaited requests in try/catch and surface a useful message.
  • Cancel stale requests with AbortController.
  • Keep API calls in a small module instead of scattering URLs through components.
  • Never put secrets in client-side code.
  • Show loading and error states so the UI never feels broken.

Common mistakes

  • Assuming fetch throws on a 404 or 500.
  • Forgetting JSON.stringify on a request body.
  • Missing the Content-Type header and receiving a parse error from the server.
  • Reading a response body twice.
  • Ignoring CORS until the browser blocks the request in production.
  • Leaving requests uncancelled and letting stale data win a race.

Where to go next

Fetch is the bridge between your front end and the world. Combine it with the DOM to render results, async/await to sequence work, and error handling to fail gracefully. From there, deepen your understanding of methods, status codes and headers, since they shape every request you send.

Checking the response

fetch only rejects on network failure. A 404 or 500 still resolves, so check res.ok.

Prefer
const res = await fetch("/api/data");
if (!res.ok) {
  throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
Avoid
const res = await fetch("/api/data");
const data = await res.json();
// silently parses error pages

Sending JSON

Tell the server what you are sending and stringify the body.

Prefer
await fetch("/api/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "Ada" }),
});
Avoid
await fetch("/api/users", {
  method: "POST",
  body: { name: "Ada" },
});

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Fetch API & AJAX?

Our interactive tutorial walks you through Fetch API & AJAX step by step — with quizzes and real code you can run in the browser.