Back to journal
·4 min readVueJavaScriptFetch APIFrontendTIL

TIL: AbortController Helps Prevent Stale Search Results in Vue

Today I learned how AbortController can make fetch-based search inputs in Vue safer by cancelling outdated requests and stopping older responses from overwriting newer UI state.

ShareCopy failed

When I build a search input, my first version is usually simple. I watch the input value, call the API, and render the results.

It works fine during the happy path. Then I type a bit faster, the network gets slower, and the UI starts showing results from an older request.

That bug is easy to miss. The latest value in the input isn’t always linked to the latest response that comes back from the server.

Today I learned that AbortController is a clean way to make this flow safer.

The problem

Imagine a user types vue, one character at a time. The app may fire requests like this:

/api/search?q=v
/api/search?q=vu
/api/search?q=vue

The last request is the one we care about. But HTTP responses don’t always return in the same order they were sent.

If the request for vu is slower than the request for vue, it may finish later and overwrite the correct results. The input says vue, but the list shows results for vu.

That’s not a huge crash. It’s worse in a way, because the UI still looks valid. It’s just wrong.

The small fix

AbortController lets us cancel a request that is no longer useful.

const controller = new AbortController();

fetch(`/api/search?q=${query}`, {
  signal: controller.signal,
});

controller.abort();

The important part is the signal. It connects the request with the controller. When abort() runs, the browser stops the request if it can.

Even if the request has already reached the server, the frontend can still avoid using that response and keep the UI tied to the latest user action.

A Vue example

In Vue, this fits nicely inside a watch.

<script setup lang="ts">
import { ref, watch } from "vue";

type SearchResult = {
  id: string;
  title: string;
};

const query = ref("");
const results = ref<SearchResult[]>([]);
const isLoading = ref(false);

watch(query, async (newQuery, _oldQuery, onCleanup) => {
  if (!newQuery.trim()) {
    results.value = [];
    return;
  }

  const controller = new AbortController();

  onCleanup(() => {
    controller.abort();
  });

  try {
    isLoading.value = true;

    const response = await fetch(
      `/api/search?q=${encodeURIComponent(newQuery)}`,
      {
        signal: controller.signal,
      }
    );

    if (!response.ok) {
      throw new Error("Search request failed");
    }

    results.value = await response.json();
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") {
      return;
    }

    console.error(error);
  } finally {
    isLoading.value = false;
  }
});
</script>

<template>
  <div>
    <input v-model="query" placeholder="Search..." />

    <p v-if="isLoading">Searching...</p>

    <ul>
      <li v-for="result in results" :key="result.id">
        {{ result.title }}
      </li>
    </ul>
  </div>
</template>

The key part is this:

onCleanup(() => {
  controller.abort();
});

Every time query changes, Vue runs the cleanup from the previous watcher callback before handling the next value. That means the old request gets cancelled before the next one becomes the active request.

One detail to watch

There is a small issue in the example above. If an old request is aborted, its finally block may still run and set isLoading to false, even though a newer request is already loading.

For many small UIs, this won’t be noticeable. Still, I prefer to guard state updates when the request belongs to a fast-changing input.

One simple way is to track whether the current watcher run is still active.

<script setup lang="ts">
import { ref, watch } from "vue";

type SearchResult = {
  id: string;
  title: string;
};

const query = ref("");
const results = ref<SearchResult[]>([]);
const isLoading = ref(false);

watch(query, async (newQuery, _oldQuery, onCleanup) => {
  if (!newQuery.trim()) {
    results.value = [];
    return;
  }

  let isActive = true;
  const controller = new AbortController();

  onCleanup(() => {
    isActive = false;
    controller.abort();
  });

  try {
    isLoading.value = true;

    const response = await fetch(
      `/api/search?q=${encodeURIComponent(newQuery)}`,
      {
        signal: controller.signal,
      }
    );

    if (!response.ok) {
      throw new Error("Search request failed");
    }

    const data = await response.json();

    if (isActive) {
      results.value = data;
    }
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") {
      return;
    }

    console.error(error);
  } finally {
    if (isActive) {
      isLoading.value = false;
    }
  }
});
</script>

This keeps the UI update connected to the request that still matters.

It’s a small guard, but it helps avoid weird loading flickers and stale state when the user types quickly.

When I’d use it

I’d reach for AbortController in any Vue UI where requests can become outdated. Search boxes are the obvious case, but the same issue can appear with filters, autocomplete fields, typeahead dropdowns, route params, or tabs that load remote data.

It’s not a replacement for debouncing. I’d usually use both.

Debouncing reduces how many requests the app sends. AbortController handles the requests that are already in progress and should no longer affect the UI.

Takeaway

The latest response isn’t always the correct response.

When the user changes the input, route, selected filter, or active tab, older requests may no longer be useful. Cancelling them makes the UI easier to trust and prevents small race conditions from showing the wrong data.

For me, AbortController is now one of those small browser APIs that belongs close to every fetch-based interaction.

ShareCopy failed