Infinite Scroll Controller
advancedIntersectionObserver, in-flight guards, end-of-data states, and the sentinel pattern — the pagination question every feed team asks.
Build an autocomplete/typeahead controller in JavaScript that composes debounce, AbortController, result caching, and out-of-order response guards — the classic machine-coding round.
Build the logic layer of a search-as-you-type box: as the user types, fetch suggestions — without hammering the API on every keystroke, and without ever showing results for an old query.
This is the machine-coding question senior frontend loops have converged on, because it's not a new algorithm — it's a composition test of four things this catalog covers separately, and the grading is whether you know why each layer exists:
t=0 user types "re" → request A fired
t=80 user types "rea" → request B fired
t=210 B responds (fast server hit) → UI shows results for "rea" ✓
t=340 A responds (slow) → UI shows results for "re" ✗✗✗The user sees results for a query they typed two states ago, with "rea" still in the box. This race is the most common real-world async UI bug in existence, and every interviewer probes for it. There are two independent defenses — use both:
.then queue when you abort).The one-sentence version interviewers reward: abort is an optimization; the last-write-wins guard is the correctness mechanism.
Framework-free controller: input events go in, callbacks come out.
function createTypeahead({
fetchSuggestions, // (query, { signal }) => Promise<results>
onResults, // (results, query) => void
onError = () => {}, // (error, query) => void — AbortErrors never reach this
onLoading = () => {}, // (isLoading) => void
minLength = 2,
debounceMs = 250,
cache = new Map(), // injectable — pass an LRU cache in production
}) {
let timerId = null;
let controller = null; // AbortController for the in-flight request
let latestQuery = ""; // the ONLY query allowed to touch the UI
async function runSearch(query) {
if (cache.has(query)) {
onResults(cache.get(query), query); // sync path: no spinner flash
return;
}
controller?.abort(); // defense 1: cancel the previous fetch
controller = new AbortController();
const { signal } = controller;
onLoading(true);
try {
const results = await fetchSuggestions(query, { signal });
if (query !== latestQuery) return; // defense 2: stale guard — the crux
cache.set(query, results);
onResults(results, query);
} catch (error) {
if (error.name === "AbortError") return; // cancelled ≠ failed
if (query !== latestQuery) return; // stale errors are noise too
onError(error, query);
} finally {
if (query === latestQuery) onLoading(false);
}
}
return {
onInput(rawQuery) {
const query = rawQuery.trim();
latestQuery = query;
clearTimeout(timerId);
if (query.length < minLength) {
controller?.abort(); // user cleared the box: stop everything
onResults([], query);
onLoading(false);
return;
}
timerId = setTimeout(() => runSearch(query), debounceMs);
},
destroy() { // unmount: no timers, no fetches, no callbacks
clearTimeout(timerId);
controller?.abort();
latestQuery = "�"; // sentinel no input can equal — all guards fail closed
},
};
}const typeahead = createTypeahead({
fetchSuggestions: (q, { signal }) =>
fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}),
onResults: renderList,
onLoading: toggleSpinner,
});
input.addEventListener("input", (e) => typeahead.onInput(e.target.value));latestQuery updates immediately, in onInput — before the debounce. The guard must compare against what the user wants now, not what last got fetched. Updating it inside runSearch reopens the race for responses that span a debounce window."rea" → "re") is the common case that hits it. The injectable cache parameter is deliberate: production wants an LRU with TTL, tests want a plain Map — policy stays out of the controller (the same separation as memoize).AbortError swallowed silently — cancellation is the expected outcome of fast typing, not an error state. Routing it to onError makes the UI flash error toasts while the user types — a real shipped bug.onLoading(false) only for the latest query — otherwise a stale request's finally kills the spinner for the new in-flight request.destroy() — component unmount must stop timers, abort fetches, and neutralize callbacks; the debounce-in-React cleanup discipline (same as debounce's cancel) applied to the whole controller.encodeURIComponent — "C++ jobs" and "a&b" must not corrupt the query string. One token of security/correctness hygiene interviewers notice.minLength mid-flight — clear results and abort; forgetting the abort leaves a response that repopulates the list after clearing.latestQuery (or destroy/reset) — the guard framework already handles it if selection routes through the controller.latestQuery state is the clean version.id < latest" but reset on cache hits/clears — query-string comparison is harder to get wrong than counter bookkeeping.AbortErrors to the user.destroy — the unmounted-component fetch is the interview's follow-up trap.aria-activedescendant, and the combobox ARIA pattern — mention role="combobox"/aria-expanded and you've covered the accessibility grading line.AbortSignal.timeout fit?" — deadline per request combined with user-driven aborts via AbortSignal.any([userSignal, AbortSignal.timeout(5000)]) — the modern composition (cancellation article).latestQuery UX logic remain yours. Knowing what the library does not solve is the point.IntersectionObserver, in-flight guards, end-of-data states, and the sentinel pattern — the pagination question every feed team asks.
O(1) get/put with least-recently-used eviction — the Map insertion-order trick, and the linked-list version interviewers ask about.
A dependency-free rating component: event delegation, hover preview vs committed state, keyboard support, and the ARIA pattern.
A concurrency-limited scheduler with priorities and cancellation — the pool question upgraded to the API-design round.