One search box, two perfectly successful requests, and the small ownership rule that keeps your interface honest.
You type a few letters, the suggestions appear, and your little search component looks ready to show someone. Then you add one more letter and watch the results quietly travel backwards.
Honestly, this is the kind of interview exercise I like because the screen gives you nowhere to hide. You can explain promises beautifully and still put suggestions for ca underneath an input that says car.
Both requests can return successfully. Your debounce can work exactly as written. Nobody has to lose their internet connection for the interface to become wrong.
Let’s build the part that prevents that, and then make the bug appear on purpose. This is a worked practice scenario, with invented timings, rather than a story about an interview I attended.
Meanwhile, if you want to try first by yourself, try this one
Start with the promise your search box makes
For this exercise, suggestions must belong to the user’s current input. Changing the input immediately clears the previous suggestions, and an empty input stays empty. We’ll send a request after a 300 ms pause and leave caching out of the first version.
That delay is a practice choice, not a universal recommendation. In a real conversation, clarify the minimum query length, expected latency, selection behavior, and whether old results should remain visible while fresh ones load.
I prefer clearing the list here because it makes the rule wonderfully easy to inspect. If the box says car, suggestions left over from ca are not allowed to pretend they belong there.
Two successful requests can still produce one wrong screen
Imagine this sequence with a trailing debounce.
At 0 ms, the input becomes
ca. Its request starts at 300 ms.At 350 ms, the user adds
r. Thecarrequest starts at 650 ms.At 700 ms, the newer request finishes and displays
carsuggestions.At 900 ms, the older request finishes and overwrites them with
catandcafe.
There is nothing wrong with the older response considered on its own. It answered the question it was asked. The mistake is letting it answer a question the user is no longer asking.
That distinction changes where you look for the fix. Making the API faster might make the bug harder to notice, but it doesn’t establish which response owns the screen.
Debounce, cancellation, and correctness have different jobs
Debounce controls when work begins. Each input change clears the pending timer and schedules a new one. If typing pauses long enough between changes, multiple requests can still be in flight.
Cancellation asks obsolete work to stop. When your request function passes an AbortSignal to fetch, aborting can interrupt the fetch or response-body consumption. It doesn’t rewind server work that already happened, and a custom asynchronous function may ignore that signal.
An ownership check decides whether a completion may change the interface. This is the part that still protects us when obsolete work finishes anyway.
React’s guidance on fetching and cleanup makes the same underlying point about ignoring responses that are no longer relevant. We’ll express that rule in a small JavaScript controller so the reasoning stays visible without getting buried in component markup.
Give every input change a new version
Keep a counter for the current search intent. Every input change increments it immediately. A request captures the version that created it, and may publish results only while that version is still current.
Here is the sneaky part. Incrementing the counter only when the next request starts leaves a gap during the debounce delay.
Suppose the user changes ca to car at 350 ms, but the old response arrives at 400 ms. The new request won’t start until 650 ms. If invalidation waits until then, the old response still looks current for another quarter of a second.
So the input event takes ownership immediately. Waiting to send the replacement request is a separate decision.
Checking only the query text also deserves care. With a sequence such as ca → car → ca, two different requests can share the same text. This exercise uses latest-intent semantics, so the first ca request does not get permission again just because the user returned to that spelling.
Keep the implementation small enough to explain
The controller below owns the timer, request version, and cancellation handle. fetchSuggestions is an injected asynchronous function returning an array. publish is a synchronous UI-state callback that does not throw or call back into the controller.
It implements the request lifecycle, not an entire accessible autocomplete widget. A browser component calls search.setQuery(input.value) on input changes and search.dispose() when it is removed.
function createSearch(fetchSuggestions, publish, delay = 300) { let version = 0; let timer; let controller; let disposed = false; function invalidate() { version += 1; clearTimeout(timer); controller?.abort(); controller = undefined; return version; } function setQuery(raw) { if (disposed) return; const mine = invalidate(); const query = raw.trim(); const current = () => !disposed && mine === version; publish({ query, status: query ? 'waiting' : 'idle', items: [] }); if (!query) return; timer = setTimeout(async () => { if (!current()) return; const request = new AbortController(); controller = request; publish({ query, status: 'loading', items: [] }); try { const items = await fetchSuggestions(query, request.signal); if (!current()) return; publish({ query, status: items.length ? 'success' : 'empty', items }); } catch (error) { if (!current() || request.signal.aborted) return; publish({ query, status: 'error', items: [] }); } finally { if (controller === request) controller = undefined; } }, delay); } function dispose() { disposed = true; invalidate(); } return { setQuery, dispose }; } Walk through the first two lines inside setQuery before admiring anything else. They make obsolete callbacks lose permission before the new timer even exists.
The same guard protects errors. Otherwise an old request could fail after a newer one succeeds and replace a perfectly useful result with an error message. Loading state needs ownership too, which is why there is no unconditional loading = false in finally.
Keep one controller per mounted search box. Recreating it on every React render gives each instance a separate timer and counter, which defeats their purpose. A React adapter needs stable ownership across renders and matching cleanup; this function is deliberately not presented as a drop-in hook.
For the fetch adapter, pass the signal through, reject unsuccessful HTTP responses, and validate the response shape before treating it as an array. Also decide whether trimming matches your product’s search semantics. Here it is an explicit simplifying assumption.
Make the failure deterministic before calling it fixed
Typing quickly and hoping to catch the bug is a frustrating test plan. Give yourself control over when each request completes instead.
Use fake timers for the debounce and deferred promises for the responses. Have the mock deliberately ignore cancellation in one test, because that proves the version guard works on its own.
Set the input to
ca, advance the fake clock by 300 ms, and confirm that exactly one request started.Change it to
car, advance another 300 ms, and confirm that the second request started.Resolve
carwith['car', 'carpet'], then resolvecawith['cat', 'cafe'], flushing promise callbacks after each completion.Confirm that the final published state still belongs to
car, contains its two items, and reports success.
Now repeat with the old response finishing during the new debounce window. The state should remain waiting with no items. That catches a different bug from simply resolving the requests backwards.
Clear the input while a request is pending and resolve it afterwards. Remove the component and resolve another pending request. Reject an old request after a newer success. Each case asks the same question about permission, so the tests reinforce the explanation instead of becoming a random edge-case collection.
For debounce itself, enter several values less than 300 ms apart and verify that only the final value starts a request after the last pause. Keep that check separate from response ordering, because a correct timer can coexist with incorrect results.
A correct request lifecycle still needs a usable interface
Please don’t let the network exercise distract you from the person using the search box. A dropdown that only works with a mouse is unfinished.
Once the lifecycle is sound, add a visible label, keyboard navigation, a clear active option, Enter to accept a suggestion, and Escape to dismiss the popup. Follow the W3C combobox pattern for the relationships between the input, popup, and active option, rather than scattering ARIA attributes until the markup looks impressive.
If choosing a suggestion or pressing Escape should keep the popup closed, those actions must also invalidate pending work. Otherwise a late completion can reopen a list the user just dismissed. That extension belongs in the component’s interaction model; the controller above only exposes query changes and disposal.
Give waiting, loading, no matches, and failure distinct states. They answer different questions for the reader of your screen, and they become much easier to test when they are represented explicitly.
Leave yourself one follow-up worth practising
Try the autocomplete and debounce exercise with the article closed, then explain what happens when the old request finishes before the new debounce timer fires. That one variation reveals whether the ownership rule has actually clicked.
My favourite way to rehearse the answer is to draw two requests and circle the moment the user changes their mind. From there, the implementation has a clear job to do.
You don’t need to make the network behave politely. You need to make sure an outdated answer cannot take over the screen.






