Working with JSON

Parsing and sending JSON with fetch, handling errors correctly, and a real-world example.

Parsing JSON responses

Most APIs respond with a JSON body. fetch() doesn't parse it for you automatically — you call .json() on the Response object, which itself returns a promise (it has to read and decode the response stream first):

Javascript
const response = await fetch('/api/products/1');
const product = await response.json();

console.log(product.name, product.price);

If the body isn't valid JSON, .json() rejects — always wrap it in a try/catch (covered below), rather than assuming every response will parse cleanly.

Sending JSON request bodies

Sending JSON is the reverse operation: convert a JavaScript object into a JSON string with JSON.stringify(), and tell the server what format it's receiving with a Content-Type header:

Javascript
async function createProduct(product) {
  const response = await fetch('/api/products', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(product),
  });

  return response.json();
}

const created = await createProduct({ name: 'Desk Lamp', price: 19.99 });

response.ok — fetch does not reject on HTTP errors

This is the single most common source of bugs in fetch() code: fetch() only rejects on a network-level failure (DNS failure, no connectivity, CORS block). A 404 Not Found or a 500 Internal Server Error is still a successful HTTP exchange as far as fetch() is concerned — the promise resolves normally, just with an error status code.

Javascript
const response = await fetch('/api/products/999999'); // doesn't exist
console.log(response.ok);     // false
console.log(response.status); // 404
// response.json() will likely still "succeed" and parse an error body,
// but the request itself did not throw

Always check response.ok (true for status codes 200–299) or response.status explicitly, and throw your own error if the request wasn't actually successful:

Javascript
async function fetchProduct(id) {
  const response = await fetch(`/api/products/${id}`);

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

  return response.json();
}

try/catch error handling with fetch

Combining try/catch with a manual response.ok check covers both failure modes: network-level failures (which reject the fetch() promise itself) and application-level failures (a valid HTTP response with an error status code):

Javascript
async function fetchProduct(id) {
  try {
    const response = await fetch(`/api/products/${id}`);

    if (!response.ok) {
      // Server responded, but with an error status (404, 500, etc.)
      const errorBody = await response.json().catch(() => null);
      throw new Error(errorBody?.message ?? `HTTP ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    // Either a network failure (fetch() itself rejected)
    // or the error we threw above for a bad status code
    console.error('Could not load product:', error.message);
    throw error; // re-throw if the caller needs to react too
  }
}

A complete real-world example: fetching and rendering a user list

Putting it all together — fetching a list of users from an API, handling loading and error states, and rendering the result into the DOM:

HTML
<ul id="user-list"></ul>
<p id="status"></p>
Javascript
async function loadUsers() {
  const list = document.getElementById('user-list');
  const status = document.getElementById('status');

  status.textContent = 'Loading users...';
  list.innerHTML = '';

  try {
    const response = await fetch('/api/users');

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

    const users = await response.json();

    if (users.length === 0) {
      status.textContent = 'No users found.';
      return;
    }

    status.textContent = '';
    for (const user of users) {
      const item = document.createElement('li');
      item.textContent = `${user.name} (${user.email})`;
      list.appendChild(item);
    }
  } catch (error) {
    status.textContent = `Failed to load users: ${error.message}`;
  }
}

loadUsers();

This example covers every step you'll repeat in almost every real AJAX call: show a loading state, make the request, check for HTTP-level success, parse the JSON, handle the empty case, render the result, and catch anything that goes wrong along the way — all without a single full page reload.

Common mistakes

  • Assuming a resolved fetch() promise means success — it only means the network request completed; you still must check response.ok or response.status.
  • Calling .json() on an error response and assuming it will always contain a message field — defensively fall back to a generic message if the shape isn't guaranteed.
  • Not showing any loading or error state in the UI, leaving the user staring at a blank screen with no feedback while a request is in flight or after it fails.

Interview questions

Q: Does fetch() throw an error for a 404 or 500 response? No. fetch() only rejects its promise for network-level failures (the request never reached a server, or was blocked by something like CORS). An HTTP response with a 4xx or 5xx status code is still a "successful" fetch as far as the promise is concerned — you must check response.ok or response.status yourself and throw your own error if needed.

Q: How would you handle both network errors and HTTP error status codes with the same fetch call? Wrap the call in try/catch to catch network-level rejections, and inside the try block, check response.ok immediately after awaiting the fetch — if it's false, throw a new Error with the status code or a message parsed from the error body. That thrown error then flows into the same catch block as a genuine network failure, so calling code only needs to handle one error path.