Fetching JSON Data with the Fetch API

1

Fetching JSON Data with the Fetch API

▶

Modern web applications rarely work in isolation — they retrieve data from servers, public APIs, and external sources to populate their interfaces dynamically. The Fetch API is the browser-native mechanism for making those network requests. It replaced the older XMLHttpRequest approach with a cleaner, promise-based design that fits naturally into contemporary JavaScript patterns. Understanding the Fetch API end-to-end — from issuing a request to rendering the results on the page — is one of the most practical skills in front-end development.

The Fetch API centers on a single global function: fetch(). At its simplest, you call fetch() with a URL string, and it returns a Promise. That Promise eventually resolves to a Response object that represents everything the server sent back: the status code, headers, and body. Because the API is promise-based, you can handle the asynchronous flow with either .then() / .catch() chaining or the more readable async / await syntax — both are fully supported and widely used in real codebases.

To make the most basic fetch request, pass a URL string as the first argument to fetch() and chain a .then() callback to receive the Response object once the server replies:

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(function(response) {
    console.log(response);        // Response object
    console.log(response.status); // e.g., 200
    console.log(response.ok);     // true if status is 200–299
  });

A critically important detail: a resolved Promise does not mean the request succeeded. The fetch() Promise only rejects for genuine network failures — things like a dropped internet connection or a DNS lookup that returns nothing. If the server responds with 404 Not Found or 500 Internal Server Error, the Promise still resolves, because the server did reply. You must inspect the Response object yourself to know whether the HTTP status indicates success. The two most useful properties for this check are:

  • response.ok — a boolean that is true only when the status code falls in the 200–299 range.
  • response.status — the numeric HTTP status code, such as 200, 404, or 500.

Once you have a successful Response, you need to extract the data from its body. For JSON APIs, you do this by calling response.json(). This method reads the entire response body stream and parses it as JSON — and, because reading a stream is itself asynchronous, response.json() returns another Promise. You therefore chain a second .then() to work with the actual parsed data:

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(function(response) {
    return response.json(); // returns a Promise
  })
  .then(function(data) {
    // data is now a plain JavaScript object
    console.log(data.title);
    console.log(data.body);
  });

The value you receive in the second .then() callback is a native JavaScript object or array — there is no need to call JSON.parse() yourself; response.json() handles that internally. If the response body is a JSON array (as many list endpoints return), data will be a JavaScript array, and you can iterate over it immediately with forEach, map, and so on.

While .then() chaining works well, most developers find the async / await syntax significantly easier to read, especially once error handling is added. The rules are straightforward: wrap your logic in a function declared with the async keyword, then use await before any expression that returns a Promise. Execution inside the function pauses at each await until that Promise settles, and the resolved value is returned directly — no callback nesting required:

async function loadPost() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
  const data = await response.json();
  console.log(data.title);
}

loadPost();

Notice that await response.json() appears on its own line, pausing again until the body has been fully read and parsed. The end result is identical to the chained version, but the code reads almost like synchronous step-by-step instructions.

Error handling deserves careful attention because there are two distinct kinds of failure: network errors (the fetch Promise rejects) and HTTP errors (the fetch Promise resolves but response.ok is false). Handling both correctly requires checking response.ok inside the async flow and throwing an error manually when it is false, so that a surrounding try/catch block can catch it alongside any genuine network failures:

async function loadPost() {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');

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

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

  } catch (error) {
    console.error('Request failed:', error.message);
  }
}

loadPost();

The try block covers everything: if fetch() itself throws (network down), execution jumps to catch. If the status is bad, the explicit throw also routes to catch. If response.json() fails because the body is malformed, that too is caught. This pattern produces robust, predictable error handling in a compact structure.

When working with the equivalent .then() / .catch() chain, the same manual throw is needed, and a single .catch() at the end of the chain handles all rejection sources:

fetch('https://jsonplaceholder.typicode.com/posts/1')
  .then(function(response) {
    if (!response.ok) {
      throw new Error('HTTP error: ' + response.status);
    }
    return response.json();
  })
  .then(function(data) {
    console.log(data.title);
  })
  .catch(function(error) {
    console.error('Request failed:', error.message);
  });

After fetching and parsing JSON, the final step is actually displaying the data on the page. The parsed object or array is just ordinary JavaScript, so you access its values with standard dot notation (data.title) or bracket notation (data['user-id']) and write those values into the DOM. The key DOM methods you will reach for are:

  • document.getElementById(id) — selects a single element by its id attribute.
  • document.querySelector(selector) — selects the first element matching any CSS selector.
  • element.textContent — sets the text content of an element safely (no HTML parsing).
  • element.innerHTML — sets the inner HTML of an element (use carefully with untrusted data).
  • document.createElement(tag) — creates a new DOM element programmatically.
  • parent.appendChild(child) — adds a created element to the document tree.

Here is a complete, practical example. Suppose the API returns a single post object with id, title, and body fields, and the HTML page has a container <div id="post"></div>:

async function displayPost() {
  const container = document.getElementById('post');

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');

    if (!response.ok) {
      throw new Error('Could not load post. Status: ' + response.status);
    }

    const post = await response.json();

    // Create and populate a heading for the title
    const heading = document.createElement('h2');
    heading.textContent = post.title;

    // Create and populate a paragraph for the body text
    const paragraph = document.createElement('p');
    paragraph.textContent = post.body;

    // Append both elements to the container
    container.appendChild(heading);
    container.appendChild(paragraph);

  } catch (error) {
    container.textContent = 'Error: ' + error.message;
  }
}

displayPost();

When the API returns a JSON array — a common situation for list endpoints — you iterate over it and create a DOM element for each item. The pattern below fetches a list of users and builds an unordered list:

async function displayUsers() {
  const list = document.getElementById('user-list');

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users');

    if (!response.ok) {
      throw new Error('Failed to fetch users. Status: ' + response.status);
    }

    const users = await response.json(); // users is an array

    users.forEach(function(user) {
      const item = document.createElement('li');
      item.textContent = user.name + ' — ' + user.email;
      list.appendChild(item);
    });

  } catch (error) {
    list.textContent = 'Error: ' + error.message;
  }
}

displayUsers();

To consolidate the complete flow, the table below summarises each stage of a fetch-and-display operation, the key API involved, and what you need to watch out for:

Stage Key API / Property Returns Important Notes
Issue the request fetch(url) Promise → Response Only rejects on network failure, not on HTTP errors
Check HTTP status response.ok / response.status Boolean / Number Throw manually if ok is false to trigger catch handlers
Parse the body response.json() Promise → Object / Array Must be awaited or chained; body can only be read once
Access data Dot / bracket notation Any JS value No JSON.parse() needed; result is already a JS value
Display in browser getElementById, createElement, textContent DOM mutation Prefer textContent over innerHTML for untrusted data
Handle errors try/catch or .catch() Error object Covers both network failures and manually thrown HTTP errors

Putting all of these pieces together gives you a complete, defensive workflow: request the data, confirm the server responded successfully, parse the JSON body, and render the resulting JavaScript values into the DOM — with a catch block standing by to handle anything that goes wrong at any step.

NotesUses the public JSONPlaceholder API (https://jsonplaceholder.typicode.com) as a free, no-auth example endpoint suitable for learners to test immediately in a browser console or simple HTML page.