← All articles

Article / JavaScript / Testing

Build reliable JavaScript search with debounce, abort, and request ordering

Build an async search controller that handles stale responses, loading states, errors, and cleanup—with deterministic tests for the races that are easy to miss.

One brass sphere reaches a search lens while older paths divert into folded paper trays.
Only the result belonging to the current query should reach the interface.

A search field can show the wrong result even when every request succeeds.

Someone types react, pauses long enough to start a request, then types react native. The second request finishes first. The interface shows the right results for a moment, until the slower first request replaces them with results for react.

Debouncing reduces how often you send requests. It does not decide which response is allowed to update the page. Aborting a request helps stop unnecessary work, but your UI also needs an explicit rule for ownership: only the latest input can publish a result.

This walkthrough builds that rule into a small controller, connects it to native HTML, and tests response ordering without real network calls or timing guesses. You need basic JavaScript promises and ES modules. The browser example expects a same-origin search endpoint; the tests run independently with Node's built-in test runner.

Trace the race before writing the fix

Consider this sequence:

Time User or network action Correct UI behavior
0 ms User types react Wait briefly before requesting
250 ms First request starts Show loading for react
300 ms User types react native First request immediately loses ownership
400 ms First request finishes Ignore its result
550 ms Second request starts Show loading for react native
650 ms Second request finishes Show its results

The critical moment is at 300 ms. The first request becomes stale when the input changes, even though the next request has not started yet. If you increment your request counter only inside the debounced function, the old request can still update the page during that waiting period.

The same ownership rule applies to errors and loading indicators. A stale rejection must not replace a newer success with an error. An old request's finally block must not turn off the current request's spinner.

We will represent the UI with five states: idle, waiting, loading, success, and error. Each state includes the query it belongs to. Keeping this association explicit makes it easier to inspect a report of “the results do not match what I typed.”

Give debounce, cancellation, and ownership separate jobs

Debounce waits for a pause in input. Cancellation asks the current operation to stop. A monotonically increasing version determines whether an operation still owns the UI.

Use all three for different reasons:

  • A timer avoids sending a request for every keystroke.
  • An AbortController signals that the previous request is no longer needed.
  • A version check prevents stale work from committing, including work that does not cooperate with cancellation.

AbortController can cancel a fetch and response-body consumption through its signal. It cannot undo arbitrary application work or guarantee that a server has stopped processing a request. AbortController reference

This example clears previous results on input changes. That is a deliberate UX choice: visible results always belong to the displayed query. You can keep previous results instead, but label them as previous results until the new query completes. Do not silently present them as current.

Create the controller

Save this as search-controller.mjs. The controller knows nothing about HTML or the endpoint. It accepts a search function and publishes states through onState.

The optional scheduler is a small test seam. In the browser it uses real timers; in tests we can explicitly release the pending debounce callback.

js
export function createSearchController({
  search,
  onState,
  delay = 250,
  schedule = (fn, ms) => setTimeout(fn, ms),
  cancel = handle => clearTimeout(handle),
}) {
  let version = 0;
  let timer = null;
  let active = null;
  let disposed = false;

  function invalidate() {
    version += 1;
    if (timer !== null) cancel(timer);
    timer = null;
    active?.abort();
    active = null;
    return version;
  }

  async function run(query, ticket) {
    if (disposed || ticket !== version) return;
    const controller = new AbortController();
    active = controller;
    const ownsState = () => !disposed && ticket === version;
    onState({ status: 'loading', query, items: [] });

    try {
      const items = await search(query, { signal: controller.signal });
      if (ownsState()) onState({ status: 'success', query, items });
    } catch (error) {
      if (ownsState()) {
        onState({
          status: 'error',
          query,
          items: [],
          message: error instanceof Error ? error.message : 'Search failed',
        });
      }
    } finally {
      if (active === controller) active = null;
    }
  }

  return {
    setQuery(value) {
      if (disposed) return;
      const ticket = invalidate();
      const query = value.trim();
      if (query.length < 2) {
        onState({ status: 'idle', query, items: [] });
        return;
      }
      onState({ status: 'waiting', query, items: [] });
      timer = schedule(() => {
        timer = null;
        return run(query, ticket);
      }, delay);
    },
    dispose() {
      if (disposed) return;
      disposed = true;
      invalidate();
    },
  };
}

Every input change invalidates prior work before scheduling anything new. Clearing the input also increments the version, so a response cannot repopulate a search that the user has just cleared. Repeating the same query starts a fresh attempt; this keeps retry behavior simple.

The finally block only releases its own controller reference. It does not publish loading: false. Loading ends through the current request's success or error state, or through a later input change. This avoids a common race where cleanup from one request changes another request's UI.

There is no special AbortError branch because the cancellation initiated here always happens after invalidation. Its rejection no longer owns state and is ignored. If the search adapter independently fails or aborts the current request, the controller reports an error rather than leaving the interface stuck in loading.

Treat onState as a synchronous renderer that should not throw or call back into setQuery. Keep network and validation failures in the search adapter. That makes the controller's responsibilities small enough to reason about.

Add an endpoint adapter with a clear response contract

Suppose your endpoint returns an array of results shaped like this:

json
[
  { "id": "async-search", "title": "Reliable async search" },
  { "id": "list-performance", "title": "React Native list performance" }
]

Create search-api.mjs:

js
export async function searchArticles(query, { signal }) {
  const params = new URLSearchParams({ q: query });
  const response = await fetch(`/api/search?${params}`, {
    signal,
    headers: { Accept: 'application/json' },
  });

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

  const items = await response.json();
  if (!Array.isArray(items) || !items.every(item =>
    item && typeof item.id === 'string' && typeof item.title === 'string'
  )) {
    throw new Error('The search response has an unexpected format');
  }
  return items;
}

fetch can resolve normally for an HTTP error such as 404 or 500, so check response.ok. Parsing JSON is a separate asynchronous operation and can also fail. Both errors flow into the controller's current-request check. Using Fetch

The validation matches exactly what the renderer needs. If you later add links, validate the destination and construct it deliberately rather than accepting arbitrary HTML from the endpoint. Do not use a nonempty array as proof that the response has the expected shape.

This article does not create /api/search for you. Connect the adapter to your existing service, or provide an asynchronous function over a local dataset. The request-ordering behavior is independent of the backend. For a small static article collection, local search can avoid this network path entirely.

Connect the controller to accessible HTML

Use an explicit label and a separate status element. This is a results list rather than an autocomplete combobox: it does not promise arrow-key selection or a popup of selectable suggestions.

html
<label for="article-search">Search articles</label>
<input id="article-search" type="search" autocomplete="off">
<p id="search-status" role="status" aria-live="polite"></p>
<ul id="search-results" aria-label="Search results"></ul>
<script type="module" src="./search-page.mjs"></script>

Create search-page.mjs:

js
import { createSearchController } from './search-controller.mjs';
import { searchArticles } from './search-api.mjs';

export function mountSearch(root = document) {
  const input = root.querySelector('#article-search');
  const status = root.querySelector('#search-status');
  const results = root.querySelector('#search-results');
  const events = new AbortController();
  let composing = false;

  const controller = createSearchController({
    search: searchArticles,
    onState(state) {
      results.replaceChildren();
      results.setAttribute('aria-busy', String(state.status === 'loading'));
      if (state.status === 'idle') {
        status.textContent = 'Enter at least two characters.';
      } else if (state.status === 'waiting') {
        status.textContent = 'Waiting for you to finish typing…';
      } else if (state.status === 'loading') {
        status.textContent = `Searching for “${state.query}”…`;
      } else if (state.status === 'error') {
        status.textContent = 'Search could not finish. Edit the query to retry.';
      } else {
        status.textContent = `${state.items.length} results for “${state.query}”.`;
        const fragment = document.createDocumentFragment();
        for (const item of state.items) {
          const row = document.createElement('li');
          row.textContent = item.title;
          fragment.append(row);
        }
        results.append(fragment);
      }
    },
  });

  input.addEventListener('compositionstart', () => {
    composing = true;
    controller.setQuery('');
  }, { signal: events.signal });
  input.addEventListener('compositionend', () => {
    composing = false;
    controller.setQuery(input.value);
  }, { signal: events.signal });
  input.addEventListener('input', event => {
    if (!composing && !event.isComposing) controller.setQuery(input.value);
  }, { signal: events.signal });
  controller.setQuery(input.value);

  return () => {
    events.abort();
    controller.dispose();
  };
}

const unmountSearch = mountSearch();
// In an application router, call unmountSearch when this view is removed.

textContent renders returned titles as text, so a title containing angle brackets does not become markup. The public error message is intentionally simpler than the adapter's diagnostic message. If you add logging, avoid sending private queries or response bodies without considering what your logs retain.

Composition events matter for input methods that build a character through several intermediate changes. This example invalidates existing work when composition begins and waits until composition ends before searching the composed text. Some browsers may emit another input event afterward; resetting the timer for the same value is harmless here.

The returned cleanup function removes the listeners and invalidates pending work. Call it from your router or component teardown when removing this view. Merely detaching the input element does not tell a running promise that its result is no longer wanted. Event listeners registered with a signal can be removed by aborting that signal. DOM events

Test ordering by controlling completion

A test that waits 300 milliseconds and hopes requests finish in the right order is testing the machine's timing as much as your code. Instead, keep promises unresolved until the test chooses to resolve or reject them.

Save this as search-controller.test.mjs beside the controller:

js
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSearchController } from './search-controller.mjs';

function harness() {
  let pending;
  const states = [];
  const requests = [];
  const controller = createSearchController({
    schedule(fn) { pending = fn; return fn; },
    cancel(handle) { if (pending === handle) pending = undefined; },
    onState(state) { states.push(state); },
    search(query, { signal }) {
      return new Promise((resolve, reject) => {
        requests.push({ query, signal, resolve, reject });
      });
    },
  });
  return {
    controller,
    requests,
    states,
    last: () => states.at(-1),
    start() {
      assert.ok(pending, 'A debounce callback must be pending');
      const callback = pending;
      pending = undefined;
      return callback();
    },
  };
}

test('old result cannot commit during the next debounce wait', async () => {
  const h = harness();
  h.controller.setQuery('react');
  const first = h.start();
  h.controller.setQuery('react native');
  assert.equal(h.requests[0].signal.aborted, true);
  h.requests[0].resolve([{ id: 'old', title: 'Old result' }]);
  await first;
  assert.equal(h.last().status, 'waiting');
  assert.equal(h.last().query, 'react native');
  h.controller.dispose();
});

test('a stale error cannot replace a newer success', async () => {
  const h = harness();
  h.controller.setQuery('react');
  const first = h.start();
  h.controller.setQuery('react native');
  const second = h.start();
  h.requests[1].resolve([{ id: 'new', title: 'Current result' }]);
  await second;
  h.requests[0].reject(new Error('Old request failed'));
  await first;
  assert.equal(h.last().status, 'success');
  assert.equal(h.last().items[0].id, 'new');
  h.controller.dispose();
});

test('clearing input prevents a pending response from restoring results', async () => {
  const h = harness();
  h.controller.setQuery('react');
  const first = h.start();
  h.controller.setQuery('');
  h.requests[0].resolve([{ id: 'old', title: 'Old result' }]);
  await first;
  assert.equal(h.last().status, 'idle');
  assert.deepEqual(h.last().items, []);
  h.controller.dispose();
});

test('disposing the controller prevents later updates', async () => {
  const h = harness();
  h.controller.setQuery('react');
  const first = h.start();
  h.controller.dispose();
  const count = h.states.length;
  h.requests[0].resolve([]);
  await first;
  h.controller.setQuery('another query');
  assert.equal(h.states.length, count);
});

Run the tests:

sh
node --test search-controller.test.mjs

The fake search function deliberately ignores abort signals. That is useful: the tests verify ownership rather than accidentally passing because cancellation prevented every stale completion. They also verify that the signal is aborted, so cancellation is not forgotten while testing the stronger guarantee.

The first test targets the gap between input and the next request. The second reverses completion order and uses a rejection for the stale request. The last two check that clearing and leaving the view are meaningful state transitions, not cosmetic operations.

Finish with integration checks

The controller tests do not prove that your endpoint, input events, or accessibility announcements work in a browser. Exercise the actual page with a slow connection and inspect these cases:

Action Expected outcome
Type quickly, then pause Only the final pending query starts after the delay
Clear while loading Results stay empty even if the old operation finishes
Return an HTTP error or invalid JSON Current request leaves loading and shows a retry message
Search for a term with no results Success state announces zero results
Type using a composition-based input method Intermediate composition does not trigger searches
Remove the view while loading No later rendering from the disposed controller

For a larger application, also define caching, pagination, and timeouts. A cache key must include all inputs that change the results, such as language, filters, or account scope. Pagination needs its own ownership model so a page from the previous query cannot append to the current list. A timeout needs to produce a current-request error, rather than silently leaving loading active.

These are extensions to the same rule: decide which operation owns each state update. Once that rule is explicit, debounce and cancellation become useful optimizations around a correct interface.

End of note

← Back to articles

Search articles and tips

Search in