Skip to content

JavaScript Tutorial The Fetch API

Loading, Error and Empty States

Lesson 157 of 258 2 min read By Deepak Paswan Updated Oct 4, 2026

Network requests take time and sometimes fail. A good interface shows what is happening at every moment. Think of four states:

StateWhat the user needs to see
Loading“Loading…”, a spinner, a disabled button
Successthe data
Empty“No results yet”, with a helpful hint
Errora clear message, and a way to try again

One pattern for all four

index.html
HTML
<style>
  .error { color: #b00020; }
</style>
<button id="load">Load articles</button>
<p id="status" role="status"></p>
<ul id="articles"></ul>
<script>
  // Three versions of "the server": success, empty and failure
  const sources = [
    () => new Response(JSON.stringify([{ title: "Intro to fetch" }, { title: "Async patterns" }])),
    () => new Response("[]"),
    () => new Response("Server error", { status: 500 })
  ];
  let attempt = 0;
  const wait = ms => new Promise(r => setTimeout(r, ms));

  const button = document.querySelector("#load");
  const status = document.querySelector("#status");
  const list = document.querySelector("#articles");

  async function load() {
    button.disabled = true;
    status.className = "";
    status.textContent = "Loading…";
    list.replaceChildren();

    try {
      await wait(500);                                 // pretend network delay
      const response = sources[attempt++ % sources.length]();
      if (!response.ok) throw new Error("Server error " + response.status);
      const articles = await response.json();

      if (articles.length === 0) {
        status.textContent = "No articles yet. Check back soon.";
      } else {
        status.textContent = `${articles.length} articles`;
        for (const a of articles) {
          const li = document.createElement("li");
          li.textContent = a.title;
          list.append(li);
        }
      }
    } catch (error) {
      status.className = "error";
      status.textContent = "Could not load articles. Please try again.";
    } finally {
      button.disabled = false;       // always re-enable
    }
  }

  button.addEventListener("click", load);
</script>

Press the button three times: you see success, then the empty state, then an error, and each click works as a “retry”.

Why each piece matters

  • Disable the button while loading, to avoid duplicate requests.
  • finally makes sure the interface comes back to life, even after an error.
  • role="status" makes screen readers announce the message when it changes.
  • Clear old content before a new load, so stale data is not mistaken for new data.
  • Plain-language errors, never raw messages such as TypeError: Failed to fetch.

Avoid flicker

If a request usually takes 50 ms, a spinner that flashes for one frame looks worse than nothing. Show the loading state after a short delay (about 200 ms), or keep the old content visible while refreshing.

Keep the state in one place

In bigger apps, store the state as a value and render from it:

script.js
JS
let state = { status: "idle", data: null, error: null };   // idle | loading | success | error

That prevents impossible combinations, like “loading” and “error” at the same time.

lightbulb
Retry sensibly
A “Try again” button is friendly. Automatic retries should wait a little longer each time (a back-off) and stop after a few attempts, so they do not hammer a server that is struggling.