Fetching data with fetch()

Load the course dashboard's JSON with fetch, check the response, and show clear loading, error and timeout states.

  • Beginner
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Fetch and display JSON
  • Handle HTTP errors from fetch
  • Tell HTTP errors and network errors apart
  • Add a timeout with AbortController

Before you start

fetch() is the browser's built-in way to make HTTP requests from JavaScript. It returns a promise, so everything from the last three lessons applies. In this lesson the course dashboard stops using a hard-coded array and loads data/courses.json from the server, with proper loading, error and timeout handling.

The project files

The dashboard folder from lesson 26 looks like this:

Plain text
dashboard/
  index.html
  app.js
  data/
    courses.json

data/courses.json is the six-course file you saved in lesson 26. Its first entries look like this:

JSON
[
  { "id": 1, "title": "JavaScript Basics", "topic": "JavaScript", "level": "Beginner", "lessons": 12, "updated": "2026-09-14" },
  { "id": 2, "title": "CSS Grid in Practice", "topic": "CSS", "level": "Intermediate", "lessons": 8, "updated": "2026-08-02" },
  { "id": 3, "title": "Accessible Forms", "topic": "Accessibility", "level": "Intermediate", "lessons": 6, "updated": "2026-10-01" },
  ...
]

And index.html gives the script somewhere to put its output:

HTML
<!DOCTYPE html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Course dashboard</title>
  <script src="app.js" defer></script>
</head>
<body>
  <main>
    <h1>Course dashboard</h1>
    <p id="status" role="status" tabindex="-1"></p>
    <button type="button" id="retry" hidden>Try again</button>
    <ul id="course-list"></ul>
  </main>
</body>
</html>

The role="status" paragraph is a polite live region: screen readers announce changes to its text without moving focus.

Serve the folder, do not double-click it

If you open index.html by double-clicking it, the address bar shows a file:// URL, and the request for data/courses.json fails. Chrome reports something like "URL scheme "file" is not supported"; other browsers report a similar network or security error.

Browsers deliberately block fetch() from file:// pages. Requests are governed by the same-origin policy, which compares the scheme, host and port of the page and the URL it asks for. Files on disk have no proper origin, so if a downloaded HTML file could read other files next to it, any page you saved could read your documents. The fix is to serve the folder over HTTP, the way a real site is served:

Shell
npx serve dashboard

Then open the http://localhost address it prints. The VS Code Live Server extension does the same job. From here on, every fetch example assumes the page is served over http:// or https://.

A first request

Try this in the console of the served page, where top-level await works:

JavaScript
const response = await fetch("data/courses.json");
const courses = await response.json();
console.log(courses.length); // → 6

There are two awaits because there are two steps:

  1. fetch() resolves with a Response object as soon as the server has sent the status line and headers. The body may still be arriving.
  2. response.json() reads the whole body and parses it as JSON. It returns another promise.

A relative URL such as data/courses.json is resolved against the page's address, just like a link.

The Response object

MemberWhat it gives you
response.oktrue if the status is in the 200 to 299 range
response.statusThe status code, such as 200 or 404
response.statusTextThe status message, such as "Not Found" (often empty over HTTP/2)
response.headers.get(name)One response header, for example "content-type"
response.json()Promise for the parsed JSON body
response.text()Promise for the body as a string

The body can only be read once. Calling response.json() and then response.text() on the same response throws a TypeError, because the body is already used.

fetch does not reject on HTTP errors

This surprises almost everyone. Ask for a file that does not exist:

JavaScript
const response = await fetch("data/missing.json");
console.log(response.ok);     // → false
console.log(response.status); // → 404

The promise fulfilled. From fetch()'s point of view, the request worked: the server answered, and the answer was "not found". A 404 or a 500 is a valid HTTP response.

fetch() only rejects when there is no response at all:

  • the network is down or the host cannot be reached;
  • the request was blocked, for example by CORS rules or because the URL scheme is not allowed;
  • the request was aborted.

So you must check response.ok yourself and turn a bad status into an error:

JavaScript
const response = await fetch("data/courses.json");
if (!response.ok) {
  throw new Error(`Request failed with status ${response.status}`);
}
const courses = await response.json();

Never call response.json() before checking response.ok. Many servers return an HTML error page with a 404 or 500 status, and response.json() then rejects with a confusing SyntaxError about an unexpected < character, which hides the real problem.

Three ways it can fail

FailureWhat happensHow you detect it
Network errorfetch() rejectsA TypeError from fetch()
HTTP errorfetch() fulfils with ok: falseCheck response.ok
Bad JSONresponse.json() rejectsA SyntaxError

You can test a network error without unplugging anything: in DevTools, open the Network panel and set the throttling menu to Offline, then reload.

Adding a timeout with AbortController

fetch() has no timeout option. A server that never answers leaves your page saying "Loading…" for a very long time. To give up after a few seconds, use an AbortController:

JavaScript
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

try {
  const response = await fetch("data/courses.json", { signal: controller.signal });
  // ...
} finally {
  clearTimeout(timer);
}

The controller has a signal that you pass to fetch(). Calling controller.abort() cancels the request, and the fetch() promise (or a response.json() still reading the body) rejects with a DOMException whose name is "AbortError". Clearing the timer in finally stops it firing after the request has already finished.

For a plain timeout there is also a shorthand, AbortSignal.timeout(ms), supported in all current browsers and Node.js:

JavaScript
const response = await fetch("data/courses.json", {
  signal: AbortSignal.timeout(5000),
});

When that signal fires, the error's name is "TimeoutError" instead. A controller is still the tool when something else must cancel the request too, such as the user starting a new search. Lesson 53 builds on this with cancellation, retries and backoff.

The complete dashboard script

Putting it all together, here is app.js:

JavaScript
const list = document.querySelector("#course-list");
const status = document.querySelector("#status");
const retryButton = document.querySelector("#retry");

async function getJSON(url, { timeout = 8000 } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, { signal: controller.signal });
    if (!response.ok) {
      throw new Error(`The server answered ${response.status} for ${url}.`);
    }
    return await response.json();
  } finally {
    clearTimeout(timer);
  }
}

function describeError(error) {
  if (error.name === "AbortError") return "The server took too long to respond.";
  if (error instanceof SyntaxError) return "The course data could not be read.";
  if (error instanceof TypeError) return "Could not reach the server. Check your connection.";
  return error.message;
}

function createCourseItem(course) {
  const item = document.createElement("li");
  item.dataset.id = course.id;

  const heading = document.createElement("h2");
  heading.textContent = course.title;

  const details = document.createElement("p");
  details.textContent = `${course.topic}, ${course.level}, ${course.lessons} lessons`;

  item.append(heading, details);
  return item;
}

function renderCourses(items) {
  list.replaceChildren(...items.map(createCourseItem));
  status.textContent = `Showing ${items.length} courses.`;
}

async function loadDashboard() {
  status.textContent = "Loading courses...";
  list.setAttribute("aria-busy", "true");

  try {
    const courses = await getJSON("data/courses.json");
    renderCourses(courses);
  } catch (error) {
    console.error(error);
    status.textContent = describeError(error);
    retryButton.hidden = false;
  } finally {
    list.removeAttribute("aria-busy");
  }
}

retryButton.addEventListener("click", () => {
  status.focus();
  retryButton.hidden = true;
  loadDashboard();
});

loadDashboard();

A few details are worth pointing out:

  • return await response.json() keeps the timer running until the body has been read, and lets finally clear it afterwards.
  • The error checks go from most to least specific. A TypeError here usually means a network failure, but a bug in your own code can throw one too, which is why the original error is always logged with console.error.
  • createCourseItem and renderCourses are the same functions as in lesson 28; only the source of the data has changed. The course data is rendered with createElement and textContent, never innerHTML. Data from a server is untrusted input, as lesson 15 explained.
  • Hiding a focused button would drop keyboard focus to the top of the page, so the retry handler first moves focus to the status message. tabindex="-1" makes the paragraph focusable from script without adding it to the tab order.
  • aria-busy="true" tells assistive technology the list is being updated.

To see each state, rename courses.json (404), add a stray comma to it (bad JSON), or switch DevTools to Offline (network error).

Request options in brief

fetch() takes an options object as its second argument. You have used signal. Others include method, headers and body, which you need to send data to your own server:

JavaScript
const options = {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ courseId: 1 }),
};

A static file server such as serve will not accept a POST, so try this against your own back end. Cross-origin requests and CORS headers are outside the scope of this lesson.

Summary

  • Serve the project folder over HTTP. Browsers block fetch() on file:// pages because local files have no proper origin.
  • fetch(url) resolves with a Response; response.json() reads and parses the body.
  • fetch() does not reject for 404 or 500. Check response.ok before reading the body.
  • fetch() rejects with a TypeError for network failures, and response.json() rejects with a SyntaxError for bad JSON.
  • Use an AbortController (or AbortSignal.timeout()) to stop waiting after a set time, and clear your timer in finally.
  • Show loading, success and error states in a live region, render data with textContent, and keep keyboard focus somewhere sensible.

Further reading