Async UIs are mostly about controlling time
The fetch is one line. The hard part is controlling time. Autocomplete correctness collapses into a timer and a request ID — and a closure that holds them together.
Async UIs are mostly about controlling time
The first thing that surprised me building an autocomplete search is how little of the problem is actually about fetching data. The fetch itself is one line. The hard part is controlling time. Users type faster than networks respond. Requests overlap. Responses arrive out of order. The UI can easily drift into showing stale data if you are not careful.
Once I understood that, the whole system collapsed into two tiny mechanisms: a timer and a request ID.
The real problem is not fetching
At first glance, this looks harmless:
input.addEventListener("input", async () => {
const text = input.value.trim();
const data = await fetch(`/api/search?q=${text}`)
.then((r) => r.json());
results.innerHTML = data
.map((d) => `<li>${d.title}</li>`)
.join("");
});
Type something. Fetch data. Render results.
But the browser does not wait for one request to finish before starting another.
If the user types quickly:
a
ap
app
you now have three requests running simultaneously.
Request A → "a"
Request B → "ap"
Request C → "app"
The dangerous part is that responses do not return in order.
Request C finishes first
Request B finishes second
Request A finishes last
Without protection, the oldest request can overwrite the newest UI. The user typed app, but the screen suddenly shows results for a.
That bug is a race condition.
The timer solves frequency
The first mechanism is debounce.
let timer: ReturnType<typeof setTimeout> | null = null;
Every keypress resets the timer:
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
// request happens here
}, 300);
The important detail is that clearTimeout cancels the previous scheduled execution before it happens.
The flow becomes:
type "a" → start timer
type "ap" → cancel previous timer
type "app" → cancel previous timer
pause → finally execute request
The API only receives one request after the user pauses typing.
The timer is not about correctness. It is about pressure reduction:
- fewer API calls
- less backend load
- less unnecessary work
- smoother UX
The timer controls when work is allowed to start.
The request ID solves correctness
Debounce alone does not solve stale responses.
Even with fewer requests, responses can still arrive out of order.
That is what lastRequestId fixes.
let lastRequestId = 0;
Every request gets a unique ID:
const myId = ++lastRequestId;
This line looks small, but it creates the entire safety system.
Suppose the user types:
a
ap
app
The requests become:
Request A → myId = 1
Request B → myId = 2
Request C → myId = 3
The critical detail is that each request keeps its own myId forever.
lastRequestId changes globally.
myId does not.
That confused me at first because it feels like myId should become 3 everywhere once the counter updates. It does not. Every async function execution gets its own scope. Each request captures its own snapshot of the value at the moment it started.
Conceptually, memory looks like this:
Global:
lastRequestId = 3
Request A scope:
myId = 1
Request B scope:
myId = 2
Request C scope:
myId = 3
Those values are isolated from each other.
The stale-response check
When a response finishes, the request checks whether it is still the newest one.
if (myId !== lastRequestId) return;
Now the timing no longer matters.
If Request A returns late:
myId = 1
lastRequestId = 3
the request immediately exits.
If Request C returns:
myId = 3
lastRequestId = 3
the UI updates safely.
Only the latest request is allowed to touch the DOM.
Closures are the hidden mechanism
The thing that makes this work is JavaScript closures.
Every time the input handler runs, JavaScript creates a new execution context:
input.addEventListener("input", () => {
const myId = ++lastRequestId;
});
Each execution owns its own variables.
Even after the outer function finishes, the async fetch callback still retains access to that request's myId.
That is why this works at all.
Without closures, every request would read the same changing variable and the system would collapse.
The architecture is surprisingly small
The entire autocomplete correctness system reduces to two ideas:
let timer = null;
let lastRequestId = 0;
One controls request frequency.
One controls response validity.
That is enough to make an async UI feel stable, even when the network is unpredictable.