Search-as-you-type: implementation plan

Debounced input with request cancellation for the product search box in the React app

TL;DR

Today the search box fires one request per keystroke and shows whichever response lands last. This plan adds a 250 ms debounce and cancels the superseded request with AbortController, so results always match what is in the box.

Requests per search3▼ -73% vs. 11 today (good)
Debounce250 mstrial range 150 to 400 ms
Estimate8 days2 engineers
Files touched73 new

What the user sees

Four states of the search box. The key rule: old results stay on screen while a new request is loading, so the list never flashes empty.

IDLE (fewer than 2 characters)
+-----------------------------+
| (search) Search products... |
+-----------------------------+
 Type 2 or more characters
LOADING (old results stay visible)
+-----------------------------+
| (search) react ho      (...)|
+-----------------------------+
 React Hooks Handbook   (dim)
 React Hooks in Depth   (dim)
RESULTS
+-----------------------------+
| (search) react hooks     (x)|
+-----------------------------+
 React Hooks Handbook
 React Hooks in Depth
 Hooks Cheat Sheet        3 hits
EMPTY and ERROR
+-----------------------------+
| (search) zzzq            (x)|
+-----------------------------+
 No results for "zzzq"

 Error: Search failed (503)
 [ Retry ]

How the data flows

Everything between the keystroke and the screen runs in the browser except one request. The hook useSearch is the only place that owns an AbortController.

Keystroke to results
Keystroke to results7 nodes. React app (browser) contains SearchBox, useDebouncedValue, useSearch, Query cache, ResultsList User → SearchBox (keystrokes); SearchBox → useDebouncedValue (raw text); useDebouncedValue → useSearch (settled query); useSearch → Query cache (lookup); Query cache → ResultsList (hit); useSearch → GET /api/search (fetch + signal); GET /api/search → useSearch (JSON); useSearch → ResultsList (items, status)box → deb: raw textdeb → hook: settled queryhook → cache: lookupcache → list: hithook → list: items, statususer → box: keystrokeshook → api: fetch + signalapi → hook: JSONFNSearchBoxcontrolled inputFNuseDebouncedValue250 ms idleFNuseSearchowns AbortControllerCACHEQuery cacheMap, 60 s TTLFNResultsListUSERUserSVCGET /api/searchexisting endpointREACT APP (BROWSER)RAW TEXTSETTLED QUERYLOOKUPHITITEMS, STATUSKEYSTROKESFETCH + SIGNALJSON
Keystroke to results7 nodes. React app (browser) contains SearchBox, useDebouncedValue, useSearch, Query cache, ResultsList User → SearchBox (keystrokes); SearchBox → useDebouncedValue (raw text); useDebouncedValue → useSearch (settled query); useSearch → Query cache (lookup); Query cache → ResultsList (hit); useSearch → GET /api/search (fetch + signal); GET /api/search → useSearch (JSON); useSearch → ResultsList (items, status)box → deb: raw textdeb → hook: settled queryhook → cache: lookupcache → list: hithook → list: items, statususer → box: keystrokeshook → api: fetch + signalapi → hook: JSONFNSearchBoxcontrolled inputFNuseDebouncedValue250 ms idleFNuseSearchowns AbortControllerCACHEQuery cacheMap, 60 s TTLFNResultsListUSERUserSVCGET /api/searchexisting endpointREACT APP (BROWSER)RAW TEXTSETTLED QUERYLOOKUPHITITEMS, STATUSKEYSTROKESFETCH + SIGNALJSON
USERFNCACHESVC

What happens when two queries overlap

The case this feature exists for: the user keeps typing while request A is still in flight.

Typing past an in-flight request
Typing past an in-flight requestSequence between User, SearchBox, useSearch, Search API. 1. User to SearchBox: types re; 2. SearchBox to useSearch: query re after 250 ms idle; 3. useSearch to Search API: GET q=re (request A); 4. User to SearchBox: types a; 5. SearchBox to useSearch: query rea after 250 ms idle; 6. useSearch to Search API: abort A (reply); 7. useSearch to Search API: GET q=rea (request B); 8. Search API to useSearch: A rejects with AbortError (reply); 9. Search API to useSearch: B returns 200 with items (reply); 10. useSearch to SearchBox: status ok, items from B (reply)OPTA was already in flightA was already in flightUSERUserFNSearchBoxFNuseSearchSVCSearch APITYPES RE1QUERY RE AFTER 250 MS IDLE2GET Q=RE (REQUEST A)3TYPES A4QUERY REA AFTER 250 MS IDLE5effect cleanup calls A.abort()ABORT A6GET Q=REA (REQUEST B)7A REJECTS WITH ABORTERROR8ignored, state untouchedB RETURNS 200 WITH ITEMS9STATUS OK, ITEMS FROM B10
Typing past an in-flight requestSequence between User, SearchBox, useSearch, Search API. 1. User to SearchBox: types re; 2. SearchBox to useSearch: query re after 250 ms idle; 3. useSearch to Search API: GET q=re (request A); 4. User to SearchBox: types a; 5. SearchBox to useSearch: query rea after 250 ms idle; 6. useSearch to Search API: abort A (reply); 7. useSearch to Search API: GET q=rea (request B); 8. Search API to useSearch: A rejects with AbortError (reply); 9. Search API to useSearch: B returns 200 with items (reply); 10. useSearch to SearchBox: status ok, items from B (reply)optA was already in flightA was already in flightUSERUserFNSearchBoxFNuseSearchSVCSearchAPITYPES RE1QUERY REAFTER 250MS IDLE2GET Q=RE(REQUESTA)3TYPES A4QUERY REAAFTER 250MS IDLE5effect cleanup calls A.abort()ABORT A6GET Q=REA(REQUESTB)7A REJECTSWITHABORTERROR8ignored, state untouchedB RETURNS200 WITHITEMS9STATUSOK, ITEMSFROM B10

Decisions worth a look

Debounce delay

Requests fired while typing "react hooks" in a recorded burst-typing session.

Requests per search by debounce delay
Requests per search by debounce delayBar chart, 4 values of delay. requests goes from 11 (0 ms) to 2 (400 ms); highest 11 at 0 ms, lowest 2 at 400 ms.02.557.51012.50 ms · requests: 11150 ms · requests: 6250 ms · requests: 3400 ms · requests: 2116320 ms150 ms250 ms400 ms
Requests per search by debounce delayBar chart, 4 values of delay. requests goes from 11 (0 ms) to 2 (400 ms); highest 11 at 0 ms, lowest 2 at 400 ms.0510150 ms · requests: 11150 ms · requests: 6250 ms · requests: 3400 ms · requests: 2116320 ms150 ms250 ms400 ms
Data
delayrequests
0 ms11
150 ms6
250 ms3
400 ms2

Other choices

decisionchosenrejectedwhy
CancellationAbortController per effectignore-stale flag onlyCancels the network request as well as the update
Debouncehook on the valuedebounced callbackWorks with React 18 Strict Mode double effects
Cachein-memory Map, 60 sSWR or React QueryNo new dependency for one box
Min length2 characters1 characterOne letter matches most of the catalog

Code to review

1. The debounce hook

Debounce the value, not the handler. The cleanup clears the timer on every change, which is the whole trick.

// src/hooks/useDebouncedValue.ts
import { useEffect, useState } from 'react';

export function useDebouncedValue<T>(value: T, delayMs = 250): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id);
  }, [value, delayMs]);

  return debounced;
}

2. The search hook with cancellation

The cleanup function aborts the previous request. The AbortError guard stops a cancelled request from being reported as a failure.

// src/hooks/useSearch.ts
const MIN_CHARS = 2;

export function useSearch(query: string): SearchState {
  const [state, setState] = useState<SearchState>({ status: 'idle', items: [] });

  useEffect(() => {
    const q = query.trim();
    if (q.length < MIN_CHARS) {
      setState({ status: 'idle', items: [] });
      return undefined;
    }

    const controller = new AbortController();
    setState((s) => ({ ...s, status: 'loading' })); // keep old items on screen

    searchApi(q, controller.signal)
      .then((items) =>
        setState({ status: items.length ? 'ok' : 'empty', items }),
      )
      .catch((err) => {
        if (err.name === 'AbortError') return; // superseded, not a failure
        setState({ status: 'error', items: [], error: err.message });
      });

    return () => controller.abort();
  }, [query]);

  return state;
}

3. The fetch client

// src/api/search.ts
export async function searchApi(q: string, signal: AbortSignal): Promise<Item[]> {
  const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal });
  if (!res.ok) throw new Error(`Search failed (${res.status})`);
  return (await res.json()).items;
}

4. The component

// src/components/SearchBox.tsx
export function SearchBox() {
  const [text, setText] = useState('');
  const debounced = useDebouncedValue(text, 250);
  const { status, items, error } = useSearch(debounced);

  return (
    <div role="search">
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        aria-busy={status === 'loading'}
        aria-controls="search-results"
      />
      <ResultsList id="search-results" status={status} items={items} error={error} />
    </div>
  );
}

5. The test that proves cancellation

// src/hooks/useSearch.test.ts
it('aborts the previous request when the query changes', async () => {
  const signals: AbortSignal[] = [];
  vi.spyOn(api, 'searchApi').mockImplementation((_q, signal) => {
    signals.push(signal);
    return new Promise(() => {}); // never resolves
  });

  const { rerender } = renderHook(({ q }) => useSearch(q), {
    initialProps: { q: 're' },
  });
  rerender({ q: 'rea' });

  expect(signals).toHaveLength(2);
  expect(signals[0].aborted).toBe(true);
  expect(signals[1].aborted).toBe(false);
});

State machine

stateentered whenshowsleaves when
idlefewer than 2 charactershint text2 or more characters settle
loadinga request startsprevious items dimmedresponse, error or abort
ok1 or more items returnresult listquery changes
empty0 items returnNo results messagequery changes
errornon-2xx or network failuremessage and Retryquery changes or Retry

Files

filechangestatus
src/hooks/useDebouncedValue.tsnewReady
src/hooks/useSearch.tsnewReady
src/api/search.tsaccept an AbortSignalReady
src/components/SearchBox.tsxwire both hooksIn progress
src/components/ResultsList.tsxadd loading and error statesIn progress
src/hooks/useSearch.test.tsnewReady
src/flags.tsadd searchDebounce flagReady

Risks and edge cases

Test plan

casehowexpected
Overlapping queriesunit test, fake timersearlier signal aborted, later one live
Late A after Bmock A to resolve after Bresults show B
Slow networkthrottle to 3G in devtoolsold items stay visible while loading
Empty and errormock 200 with [] and 503empty message, error with Retry
Rapid backspacehold Backspace to emptyreturns to idle, no request
Strict Modemount in devat most one live request per query

Schedule

Eight working days
Eight working daysRange chart, 5 rows on a date axis. Hooks and unit tests: Oct 20, 2026 to Oct 22, 2026; API signal support: Oct 21, 2026 to Oct 23, 2026; Component and states: Oct 22, 2026 to Oct 26, 2026; Accessibility and IME pass: Oct 26, 2026 to Oct 28, 2026; Flagged rollout: Oct 28, 2026 to Oct 30, 2026. Target release at Oct 30, 2026.Oct 19Oct 21Oct 23Oct 25Oct 27Oct 29Oct 31Hooks and unit testsHooks and unit tests: Oct 20, 2026 to Oct 22, 2026API signal supportAPI signal support: Oct 21, 2026 to Oct 23, 2026Component and statesComponent and states: Oct 22, 2026 to Oct 26, 2026Accessibility and IME passAccessibility and IME pass: Oct 26, 2026 to Oct 28, 2026Flagged rolloutFlagged rollout: Oct 28, 2026 to Oct 30, 2026release
Eight working daysRange chart, 5 rows on a date axis. Hooks and unit tests: Oct 20, 2026 to Oct 22, 2026; API signal support: Oct 21, 2026 to Oct 23, 2026; Component and states: Oct 22, 2026 to Oct 26, 2026; Accessibility and IME pass: Oct 26, 2026 to Oct 28, 2026; Flagged rollout: Oct 28, 2026 to Oct 30, 2026. Target release at Oct 30, 2026.Oct 19Oct 26Hooks and unit testsHooks and unit tests: Oct 20, 2026 to Oct 22, 2026API signal supportAPI signal support: Oct 21, 2026 to Oct 23, 2026Component and statesComponent and states: Oct 22, 2026 to Oct 26, 2026Accessibility and IME passAccessibility and IME pass: Oct 26, 2026 to Oct 28, 2026Flagged rolloutFlagged rollout: Oct 28, 2026 to Oct 30, 2026release
Data
taskstartend
Hooks and unit tests2026-10-202026-10-22
API signal support2026-10-212026-10-23
Component and states2026-10-222026-10-26
Accessibility and IME pass2026-10-262026-10-28
Flagged rollout2026-10-282026-10-30

Rollout

  1. 1
    Merge behind the searchDebounce flag2026-10-26
    Priya
  2. 2
    Enable for internal staff2026-10-28
    Priya
  3. 3
    Enable for 10% of traffic and watch request volume2026-10-29
    Marcus
  4. 4
    Full rollout, remove the flag next sprint2026-10-30
    Marcus

Open questions


Back to the examples