IntermediateJavaScript · Lesson 5 of 10

Async JavaScript: Promises, async/await & fetch

Load data without freezing the page, with loading and error states.

Network requests take time. JavaScript doesn't wait and freeze the page — fetch() returns a Promise immediately, and your code continues when the response arrives. async functions with await let you write this step-by-step, like normal code.

fetch only rejects on network failure. An HTTP error such as 404 still resolves, so always check response.ok before reading response.json().

Good interfaces show three states: loading, success and error (with a retry). Run independent requests in parallel with Promise.all. This example fetches a students.json file served from the same folder.

students.jsonJSON
[
  { "id": 1, "name": "Amina Hassan", "form": 4, "average": 86.0 },
  { "id": 2, "name": "Baraka Mushi", "form": 4, "average": 54.0 },
  { "id": 3, "name": "Neema Kimaro", "form": 4, "average": 75.0 },
  { "id": 4, "name": "Rehema Mollel", "form": 3, "average": 71.3 }
]
index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Results</title>
    <script src="app.js" defer></script>
  </head>
  <body>
    <h1>Form results</h1>
    <button id="load">Load results</button>
    <p id="status" aria-live="polite"></p>
    <ol id="results"></ol>
  </body>
</html>
Live preview in a sandboxed frame
app.jsJavaScript
const statusEl = document.querySelector("#status");
const list = document.querySelector("#results");
const loadBtn = document.querySelector("#load");

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

function render(students) {
  const sorted = students.toSorted((a, b) => b.average - a.average);
  list.replaceChildren(
    ...sorted.map((s) => {
      const li = document.createElement("li");
      li.textContent = `${s.name} (Form ${s.form}) - ${s.average.toFixed(1)}`;
      return li;
    }),
  );
}

async function load() {
  statusEl.textContent = "Loading...";
  loadBtn.disabled = true;
  try {
    const students = await getJSON("students.json");
    render(students);
    statusEl.textContent = `Loaded ${students.length} students.`;
  } catch (err) {
    statusEl.textContent = `Could not load results (${err.message}). Try again.`;
  } finally {
    loadBtn.disabled = false;
  }
}

loadBtn.addEventListener("click", load);

// Parallel requests: both start at once
async function loadEverything() {
  const [students, missing] = await Promise.allSettled([getJSON("students.json"), getJSON("missing.json")]);
  console.log(students.status, missing.status, missing.reason?.message);
}
loadEverything();

Key points

  • await pauses only the async function — the page stays responsive.
  • Check response.ok; fetch doesn't throw on 404/500.
  • Always show loading and error states, and re-enable controls in finally.

Exercise

Add a search box that filters the loaded list as you type, and a "Form" dropdown filter. Then point the fetch at a wrong file name and check your error message and retry button work.

Show solution

Try the exercise yourself first — then compare your approach with this one.

Keep the loaded students in a variable and re-render whenever the search text or form filter changes — no new request needed. On failure, the status shows the error and a Retry button that simply calls load() again.

index.htmlHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Results</title>
    <script src="app.js" defer></script>
  </head>
  <body>
    <input id="search" placeholder="Search names..." />
    <select id="form-filter">
      <option value="">All forms</option>
      <option value="3">Form 3</option>
      <option value="4">Form 4</option>
    </select>
    <p id="status" aria-live="polite"></p>
    <ol id="results"></ol>
  </body>
</html>
Live preview in a sandboxed frame
app.jsJavaScript
const DATA_URL = "students.json";   // change to "studentz.json" to test the error state
const statusEl = document.querySelector("#status");
const list = document.querySelector("#results");
const search = document.querySelector("#search");
const formFilter = document.querySelector("#form-filter");
let students = [];

function render() {
  const term = search.value.trim().toLowerCase();
  const visible = students
    .filter((s) => s.name.toLowerCase().includes(term))
    .filter((s) => !formFilter.value || s.form === Number(formFilter.value));
  list.replaceChildren(
    ...visible.map((s) => {
      const li = document.createElement("li");
      li.textContent = `${s.name} (Form ${s.form}) - ${s.average.toFixed(1)}`;
      return li;
    }),
  );
  statusEl.textContent = `Showing ${visible.length} of ${students.length} students.`;
}

async function load() {
  statusEl.textContent = "Loading...";
  try {
    const response = await fetch(DATA_URL);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    students = await response.json();
    render();
  } catch (err) {
    statusEl.textContent = `Could not load results (${err.message}). `;
    const retry = document.createElement("button");
    retry.textContent = "Retry";
    retry.addEventListener("click", load);
    statusEl.append(retry);
  }
}

search.addEventListener("input", render);
formFilter.addEventListener("change", render);
load();

Check your understanding

  1. Does fetch reject its Promise when the server returns 404?

  2. What does await response.json() do?

  3. What should a good data-loading interface show?

  4. Why re-enable the Load button in a finally block?

Ask AI