JavaScript The Fetch API

The Fetch API is JavaScript’s modern, built-in way to make HTTP requests, whether you’re pulling data from a REST API, submitting a form, or talking to your own backend. It replaces the older XMLHttpRequest with a cleaner, Promise-based interface that pairs naturally with async/await. Instead of juggling callbacks and readyState checks, you write fetch(url) and get back a Promise that resolves once the server responds. To use it well, though, you need to understand a few surprising behaviors — especially around error handling — that catch nearly every developer at least once.

Overview: How the Fetch API Works

fetch() is a global function available in every modern browser and in Node.js (since version 18). Its basic signature is fetch(resource, options), and it always returns a Promise that resolves to a Response object.

The most important thing to understand is that fetch works in two stages, and each stage returns its own Promise. The first Promise — the one fetch() itself returns — resolves as soon as the server’s response headers arrive, even if the body hasn’t finished downloading yet. That’s why the very first thing you get back is a Response object, not your data. To actually read the body, you call a method like response.json() or response.text(), which returns a second Promise, because the body is delivered as a stream and may still be arriving over the network.

This two-stage design is different from libraries like Axios, which parse JSON for you automatically. It also explains fetch’s most infamous gotcha: fetch only rejects on network failure (DNS lookup failure, no connectivity, a blocked CORS preflight, or an aborted request). If the server responds with a 404 or a 500, that’s still a "successful" HTTP exchange as far as fetch is concerned — the Promise resolves normally, and it’s up to you to check response.ok or response.status yourself.

Because fetch is Promise-based, it integrates with the JavaScript event loop the same way any other asynchronous operation does: calling fetch() doesn’t block the rest of your script, and the resolution of its Promise is scheduled as a microtask once the underlying network operation completes.

Syntax

fetch('https://api.example.com/data', {
  method: 'GET',
  headers: { 'Accept': 'application/json' }
})
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.error(error));

The general shape is fetch(url, options), where:

Part Description
url The resource to request — a string or a URL object.
options.method HTTP method: 'GET' (default), 'POST', 'PUT', 'DELETE', 'PATCH', etc.
options.headers An object (or Headers instance) of request headers, e.g. Content-Type.
options.body The request payload. Must be a string, FormData, Blob, or similar — never a raw object.
options.signal An AbortSignal used to cancel the request.
options.mode / credentials Control CORS behavior and whether cookies are sent (browser only).

The Response object returned has these key members:

Member Description
response.ok true when status is in the 200–299 range.
response.status The numeric HTTP status code, e.g. 404.
response.statusText The status message, e.g. 'Not Found'.
response.headers A Headers object for reading response headers.
response.json() Reads the body and parses it as JSON. Returns a Promise.
response.text() Reads the body as plain text. Returns a Promise.

Examples

Example 1: A basic GET request

const getUser = async (id) => {
  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  const user = await response.json();
  console.log(user.name);
};

getUser(1);

Output:

Leanne Graham

This is the pattern you’ll use most often: await the fetch call to get the Response, then await response.json() to get the parsed data. Because both steps are awaited inside an async function, the code reads top-to-bottom like synchronous code even though two separate asynchronous operations are happening underneath.

Example 2: A POST request with a JSON body

const createPost = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      title: 'Learning Fetch',
      body: 'The Fetch API is promise-based.',
      userId: 1
    })
  });

  const data = await response.json();
  console.log(data);
};

createPost();

Output:

{ title: 'Learning Fetch', body: 'The Fetch API is promise-based.', userId: 1, id: 101 }

Two details matter here. First, the Content-Type: application/json header tells the server how to interpret the body. Second, JSON.stringify() converts the JavaScript object into an actual JSON string — body can never be a plain object. The mock API echoes back what was sent and adds a generated id.

Example 3: Error handling and a request timeout

const fetchWithTimeout = async (url, timeoutMs = 5000) => {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);

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

    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Request timed out');
    } else {
      console.log(`Fetch failed: ${error.message}`);
    }
    return null;
  }
};

fetchWithTimeout('https://jsonplaceholder.typicode.com/posts/9999')
  .then(data => console.log(data));

Output:

Fetch failed: HTTP error! status: 404
null

Post 9999 doesn’t exist, so the server responds with a 404. Because fetch doesn’t reject on HTTP error statuses, the code must explicitly check response.ok and throw its own error to route into the catch block. The AbortController/setTimeout pairing is the standard way to give a fetch call a deadline: if the timer fires first, controller.abort() cancels the in-flight request and fetch rejects with an error whose name is 'AbortError'.

How It Works Step by Step (Under the Hood)

When your code calls fetch(url, options), roughly this sequence happens:

  • The JS engine hands the request off to the runtime’s networking layer and immediately returns a pending Promise. Your script keeps executing — fetch never blocks the thread.
  • The networking layer opens a connection, sends the request, and waits for the server.
  • As soon as response headers arrive, the pending Promise resolves with a Response object. At this point the body may not be fully downloaded yet — it’s exposed as a readable stream.
  • Calling response.json() (or .text(), .blob(), .arrayBuffer(), .formData()) reads that stream to completion and returns a second Promise that resolves once parsing finishes.
  • Both Promise resolutions are queued as microtasks, so any await or .then() callback waiting on them runs after the current synchronous code finishes, but before the next macrotask (like a setTimeout callback).
  • If the network request itself fails outright — no DNS resolution, no connectivity, a blocked CORS preflight, or an aborted signal — the first Promise rejects. HTTP error statuses do not cause a rejection at any stage; they only affect response.ok and response.status.
  • When you pass an AbortController‘s signal into the options and later call controller.abort(), the runtime tears down the in-flight request and rejects the fetch Promise with an AbortError.

Common Mistakes

Mistake 1: Assuming .catch() handles HTTP errors like 404 or 500

fetch('https://jsonplaceholder.typicode.com/nonexistent-endpoint')
  .then(response => response.json())
  .then(data => console.log('Success:', data))
  .catch(error => console.log('This will not run for a 404:', error.message));

Output:

Success: {}

Even though the endpoint doesn’t exist and the server returns a 404, the request itself succeeded at the network level, so fetch resolves normally and the .catch() never fires. The fix is to explicitly check response.ok and throw when it’s false:

const getData = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/nonexistent-endpoint');

  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}`);
  }

  const data = await response.json();
  console.log('Success:', data);
};

getData().catch(error => console.log('Caught:', error.message));

Output:

Caught: Request failed with status 404

Mistake 2: Passing a raw object as the body instead of a string

const postData = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    body: { title: 'My Post', userId: 1 }
  });
};

postData();

Nothing is logged here, but the request itself is broken: body must be a string (or a FormData/Blob/etc.), never a plain JavaScript object. Without JSON.stringify(), the object gets coerced to the useless string '[object Object]', and without a Content-Type: application/json header the server has no idea how to parse it anyway. The fix:

const postData = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ title: 'My Post', userId: 1 })
  });
};

postData();

Best Practices

  • Always check response.ok (or response.status) before treating a fetch as successful — never rely on .catch() alone to detect HTTP errors.
  • Use async/await instead of chained .then() calls for anything beyond a single-step request; it keeps error handling in one try/catch block.
  • Always JSON.stringify() object bodies and set the matching Content-Type header.
  • Give network-facing requests a timeout via AbortController; a fetch with no explicit timeout can hang as long as the server allows.
  • Wrap fetch calls in try/catch (or attach .catch()) to handle genuine network failures, which are separate from HTTP error responses.
  • Read the response body exactly once — calling .json() or .text() a second time on the same Response throws, because the stream has already been consumed.
  • Use encodeURIComponent() when building query strings by hand, or construct URLs with the URL and URLSearchParams classes instead.
  • For file uploads or multipart forms, pass a FormData instance as the body and let the browser set the Content-Type (including the boundary) automatically — don’t set it yourself.

Practice Exercises

  1. Write an async function getTodo(id) that fetches https://jsonplaceholder.typicode.com/todos/{id} and logs only the title property of the result.
  2. Write a function safeFetch(url) that returns the parsed JSON on success, but returns null and logs a friendly message instead of throwing when the response status is not OK.
  3. Write a function fetchAll(urls) that takes an array of URLs, fetches all of them concurrently using Promise.all(), parses each response as JSON, and logs an array of the results in the same order as the input URLs.

Summary

  • The Fetch API is a Promise-based, globally available function for making HTTP requests: fetch(url, options).
  • Fetch resolves in two stages: the first Promise resolves with headers as a Response, and calling .json()/.text() returns a second Promise for the body.
  • Fetch only rejects on genuine network failure — HTTP error statuses like 404 and 500 still resolve successfully, so you must check response.ok yourself.
  • POST/PUT bodies must be strings (usually via JSON.stringify()) paired with a matching Content-Type header.
  • Use AbortController to cancel requests or enforce timeouts.
  • Prefer async/await with try/catch for readable, robust fetch code.