Clean Markup

Canceling Stale Network Requests in JavaScript with AbortController

Canceling Stale Network Requests in JavaScript with AbortController

Asynchronous race conditions are a common bug in interactive web applications. When a user types quickly into a search box or switches between dashboard tabs, multiple fetch() calls are fired in sequence. If an earlier request takes longer over the network than a later request, the stale response resolves last and overwrites the UI with outdated results.

By pairing fetch() with the browser's native AbortController interface, we can pass an AbortSignal to every outgoing request and immediately cancel any in-flight call before starting a new one.

Example

const controller = new AbortController();

// Pass controller.signal in the fetch options object
fetch("/api/search?q=clean+markup", { signal: controller.signal })
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => {
    if (error.name === "AbortError") {
      console.log("Previous request canceled cleanly.");
    }
  });

// Cancel the request if a newer query starts
controller.abort();

Input : Rapidly changing search query or tab selection
Output : Only the latest network request resolves while earlier in-flight requests are aborted immediately.

Implementation

function createSearchClient(endpoint) {
  let activeController = null;

  return async function searchQueries(query) {
    // Cancel the previous pending request if one is still running
    if (activeController) {
      activeController.abort();
    }

    activeController = new AbortController();
    const { signal } = activeController;

    try {
      const url = `${endpoint}?q=${encodeURIComponent(query)}`;
      const response = await fetch(url, {
        method: "GET",
        headers: { Accept: "application/json" },
        signal,
      });

      if (!response.ok) {
        throw new Error(`HTTP error ${response.status}`);
      }

      const payload = await response.json();
      activeController = null;
      return payload.results;
    } catch (error) {
      if (error.name === "AbortError") {
        // Ignore expected cancellations triggered by newer keystrokes
        return null;
      }
      throw error;
    }
  };
}

const fetchSuggestions = createSearchClient("/api/articles");
const searchInput = document.querySelector("#search-input");
const resultsList = document.querySelector("#search-results");

searchInput.addEventListener("input", async (event) => {
  const query = event.target.value.trim();
  if (!query) {
    resultsList.innerHTML = "";
    return;
  }

  const results = await fetchSuggestions(query);
  if (results === null) {
    // Request was superseded by a newer input event
    return;
  }

  resultsList.innerHTML = results
    .map((item) => `<li><a href="${item.url}">${item.title}</a></li>`)
    .join("");
});

Main Point: Always check for error.name === "AbortError" in your catch block so intentional request cancellations are handled silently instead of triggering user-facing error banners.

Credits

Photo by Luke Chesser

← Home