Skip to main content

🌐 Fetch API Fundamentals

Almost every app you build will need to talk to a server β€” to load a list of products, save a form, or check who's logged in. The fetch() function is the browser's built-in way to do exactly that. This lesson takes you from your very first request to a solid, error-aware pattern you'll reuse everywhere.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what the Fetch API is and how it improved on XMLHttpRequest
  • Make a basic GET request and understand fetch's two-stage promise flow
  • Check response.ok and parse a body with response.json()
  • Rewrite fetch code cleanly using async/await with try/catch
  • Recognize fetch's most common beginner gotcha: HTTP errors do not reject the promise

Estimated Time: 30–40 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a reusable getJSON() helper against a live public API.

In This Lesson

What Is the Fetch API?

The Fetch API is the modern, built-in browser interface for making network requests. Its centerpiece is a single global function β€” fetch() β€” that sends an HTTP request and returns a Promise. Because it is promise-based, it plugs directly into the async/await syntax you met earlier in this module, giving you clean, readable code without a tangle of callbacks.

πŸ’‘ An analogy: Think of fetch() as ordering takeout. You place the order (send the request) and get a receipt immediately (the promise). The receipt is not your food β€” it's a promise that food is coming. Later the delivery arrives (the response), and only then do you unwrap the bag to see what's actually inside (the body). Fetch works in exactly these two steps.

Fetch replaced an older, clunkier tool called XMLHttpRequest (XHR). Here is the short version of how browser networking evolved:

  • Full page reloads β€” every interaction meant reloading the whole page.
  • XMLHttpRequest (2005) β€” enabled AJAX (updating part of a page without a reload) but used a verbose, callback-heavy API.
  • jQuery $.ajax (2006) β€” smoothed over XHR's rough edges, but required an external library.
  • Fetch API (2015) β€” native, promise-based, and now supported in every modern browser.

πŸ“– Key Terms

Promise: an object representing a value that isn't ready yet β€” it will eventually resolve with a result or reject with an error.

Response: the object fetch gives you once the server replies; it holds the status, headers, and a stream for the body.

Endpoint: a specific URL on a server that responds to requests, e.g. https://api.example.com/users.

Your First Request

The simplest possible use of fetch is a GET request to retrieve some data. Here it is with the classic .then() chain, so you can see every step explicitly:

// A basic GET request
fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then((response) => {
    // Stage 1: the server has replied with headers + status.
    // fetch does NOT throw on 404/500, so we check ourselves:
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json(); // start reading & parsing the body
  })
  .then((data) => {
    // Stage 2: the body has finished downloading and parsing.
    console.log('Data received:', data);
  })
  .catch((error) => {
    console.error('Fetch failed:', error);
  });

Notice there are two .then() blocks. That is not an accident β€” it reflects how fetch actually works, which is the single most important idea in this lesson.

The Two-Stage Promise Flow

A fetch call resolves in two separate stages. The first promise resolves as soon as the response headers arrive β€” before the body has finished downloading. Reading the body (with response.json(), response.text(), and friends) returns a second promise, because the body may still be streaming in.

The two-stage fetch promise flow A fetch call returns a first promise that resolves to a Response object when headers arrive. Calling response.json() returns a second promise that resolves to the parsed data once the body finishes downloading. fetch(url) returns Promise Response status + headers (body not read yet) .json() returns Promise data object await Stage 1 β€” headers arrive Stage 2 β€” body downloads
Figure 1 β€” Fetch resolves twice: first to a Response when headers arrive, then to the parsed data when the body finishes. That is why you see two awaits (or two .then()s) in typical fetch code.

Once you internalize this, the shape of every fetch you write makes sense: await the response, check it, then await the body.

Fetch with Async/Await

The .then() chain works, but async/await reads like ordinary step-by-step code and makes error handling with try/catch natural. This is the style you should reach for by default:

async function fetchTodo(id) {
  try {
    const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    const data = await response.json();
    console.log('Data received:', data);
    return data;
  } catch (error) {
    console.error('Fetch failed:', error);
    throw error; // re-throw so the caller can react too
  }
}

fetchTodo(1);

Read it top to bottom: send the request, wait for the response, verify it succeeded, wait for the body, return the data. The try/catch catches both network failures and the error we throw ourselves on a bad status.

πŸ’‘ Why re-throw?

Logging an error and swallowing it hides problems from whatever called your function. Re-throwing (or returning a clear failure value) lets the caller decide how to respond β€” show a toast, retry, or fall back to cached data.

The Big Gotcha: HTTP Errors Don't Reject

This trips up nearly every newcomer, so it deserves its own section. A fetch promise only rejects on a network-level failure β€” no connection, DNS failure, CORS block, or a request you aborted. An HTTP error status such as 404 Not Found or 500 Internal Server Error is considered a successful round trip: the server was reached and it answered. The promise happily resolves.

⚠️ This looks correct but is broken

// ❌ BUG: a 404 or 500 slips through as if it succeeded
async function brokenGet(url) {
  const response = await fetch(url);
  const data = await response.json(); // may throw on an error page's HTML!
  return data;
}

If the server returns a 404 with an HTML error page, response.json() throws a confusing parse error β€” or worse, an error body parses fine and your app treats failure as data.

βœ… The fix: always check response.ok

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

response.ok is true only for statuses in the 200–299 range. Make this check a reflex in every request you write.

Reading the Response Body

The Response object can hand you the body in several formats. You pick the method that matches what the server sent. Each returns a promise and β€” importantly β€” can only be called once, because the body is a stream that is consumed as you read it.

MethodResolves toUse when the response is…
response.json()JavaScript object/arrayJSON β€” by far the most common for APIs
response.text()StringPlain text, HTML, CSV, or XML
response.blob()BlobBinary files like images or PDFs
response.formData()FormDataA multipart/form-data reply
response.arrayBuffer()ArrayBufferRaw bytes you'll process yourself

For example, fetching an image as a Blob and showing it on the page:

async function showImage(url, imgElement) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`Failed: ${response.status}`);

  const blob = await response.blob();
  const objectUrl = URL.createObjectURL(blob);
  imgElement.src = objectUrl;

  // Free the memory once the browser has loaded it
  imgElement.onload = () => URL.revokeObjectURL(objectUrl);
}

⚠️ Bodies are single-use

Calling both response.json() and response.text() on the same response throws "body stream already read". If you genuinely need the body twice, call response.clone() first and read each copy once.

Hands-on Exercise

πŸ‹οΈ Build a reusable getJSON() helper

Objective: Write one small function you can drop into any project to fetch JSON safely, then use it against a real public API that needs no key.

Instructions:

  1. Write an async function getJSON(url) that fetches the URL, throws a clear error if !response.ok, and otherwise returns the parsed JSON.
  2. Call it with https://jsonplaceholder.typicode.com/users and log how many users came back.
  3. Call it again with a deliberately wrong path like .../userzzz and confirm your error branch runs (not a silent success).
  4. Wrap the calls in try/catch and print a friendly message on failure.
πŸ’‘ Hint

Remember the reflex from this lesson: a 404 still resolves. Your if (!response.ok) check is what turns that 404 into an actual thrown error your catch can see.

βœ… Sample solution
async function getJSON(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Request to ${url} failed: ${response.status}`);
  }
  return response.json();
}

async function main() {
  try {
    const users = await getJSON('https://jsonplaceholder.typicode.com/users');
    console.log(`Loaded ${users.length} users`);

    // This path 404s -> our check throws -> catch runs
    await getJSON('https://jsonplaceholder.typicode.com/userzzz');
  } catch (error) {
    console.error('Something went wrong:', error.message);
  }
}

main();

🎯 Quick Quiz

Question 1: A server responds with status 404. What does the fetch() promise do?

Question 2: Why does typical fetch code contain two await statements?

Question 3: You call response.json() and then response.text() on the same response. What happens?

Best Practices

βœ… Do

  • Check response.ok (or the status) on every request before reading the body.
  • Prefer async/await with try/catch for readability.
  • Centralize fetching in a small helper so error handling lives in one place.
  • Match the body method to the content: json() for JSON, blob() for binary.

⚠️ Don't

  • Don't assume a resolved promise means success β€” a 500 resolves too.
  • Don't read the same body twice without clone().
  • Don't reach for a library like axios before you understand fetch; the fundamentals here transfer directly.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Fetch is promise-based and native to every modern browser β€” no library needed.
  • It resolves in two stages: first a Response, then the parsed body.
  • HTTP errors do not reject β€” always check response.ok.
  • Pick the right body reader (json, text, blob…); each is single-use.
  • async/await + try/catch gives you the cleanest, most robust code.

πŸ“š Further Reading

πŸš€ What's Next?

You can now make and verify a request. Next we dig into the second argument to fetch β€” the options object β€” where you set the HTTP method, headers, body, credentials, and more to control exactly how a request is sent.

πŸŽ‰ Nice work!

The two-stage flow and the response.ok reflex are the whole foundation. Everything else in this module builds on them.