What you'll be able to do

By the end of this page you should be able to create an AbortController, pass signal to fetch, abort the previous request when a new one starts, and ignore AbortError cleanly so stale responses do not overwrite the UI.

Previous post: Promise.all and friends.

Who this is for

  • People building search-as-you-type boxes
  • Beginners whose slow response arrives after a fast one and shows wrong data
  • Anyone who will later meet the same idea in React effects

You can skip this if abort patterns are familiar. Come back when race conditions show wrong results.

The stale response problem

Scenario A — user types “cat” then “cats”. Request for “cat” is slow; “cats” returns first; then “cat” arrives late and overwrites the list with old data.

Scenario B — tab change. A request from the previous tab finishes and updates the wrong panel.

Aborting the old request (or ignoring its result) fixes the race.

AbortController + fetch

const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort(); // cancels that fetch

Scenario A — new keystroke. Abort the previous controller; start a new one.

Scenario B — component unmount / page leave. Abort so work does not continue uselessly.

Pattern: one controller at a time

let controller = null;

async function search(q) {
  if (controller) controller.abort();
  controller = new AbortController();
  try {
    const res = await fetch("/api/search?q=" + encodeURIComponent(q), {
      signal: controller.signal,
    });
    if (!res.ok) throw new Error("HTTP " + res.status);
    return res.json();
  } catch (err) {
    if (err.name === "AbortError") return null; // expected
    throw err;
  }
}

Scenario A — aborted. Return quietly; do not show an error toast.

Scenario B — real failure. Re-throw or show a message.

A tiny practice file

<!DOCTYPE html>
<html>
  <body>
    <input id="q" placeholder="Type to search (demo)" />
    <pre id="out"></pre>
    <script>
      let controller = null;
      const out = document.getElementById("out");
      document.getElementById("q").addEventListener("input", async function (e) {
        const q = e.target.value.trim();
        if (controller) controller.abort();
        controller = new AbortController();
        out.textContent = "loading…";
        try {
          // Demo: fake delay with fetch to httpbin or local; here we abort a timer-like fetch
          const res = await fetch(
            "https://jsonplaceholder.typicode.com/todos/" + (q.length || 1),
            { signal: controller.signal }
          );
          if (!res.ok) throw new Error("HTTP " + res.status);
          const data = await res.json();
          out.textContent = JSON.stringify(data, null, 2);
        } catch (err) {
          if (err.name === "AbortError") {
            out.textContent = "aborted (typing…)";
            return;
          }
          out.textContent = err.message;
        }
      });
    </script>
  </body>
</html>

Type quickly — you should see aborts between keystrokes.

Mistakes I see a lot

1. Treating AbortError as a user-facing failure.

2. Reusing one aborted controller. Create a new AbortController per request.

3. Only aborting and still applying late results from a non-aborted path. Guard with the signal or a request id.

4. Forgetting to pass signal into fetch. abort() does nothing useful then.

What to try before the next post

  1. Abort on each input event; confirm AbortError handling.
  2. Log when a late response would have been wrong without abort.
  3. Build the tiny HTML file.

This closes Phase 8 (async). Next we begin Phase 9 — mental models: this in plain English.

Try this next outside the series

Async code gets much easier when you see the same ideas on the browser side and the server side.