Debugging Stale Browser State
The bug report said “search sometimes goes backwards.” A user typed a service name, saw the right results, and then watched an older response replace them. The browser was not confused. Our code had confused the last response to finish with the latest request we intended to show.
This fictional debugging write-up is a typography fixture. Any timings below are illustrative, not benchmark results.
Reproduce the ordering
“It only happens on slow Wi-Fi.”
That is a reproduction hint, not evidence that the network is broken.
The important question is which response is still allowed to update the screen.
We can reproduce the problem without a real server. This JavaScript block includes regexes, template literals, optional chaining, async functions, and a deliberately long data record.
const fixtures = [
{ id: "svc-01", name: "billing-worker", description: "Processes invoice reconciliation events for organizations with multiple billing accounts, regional pricing agreements, and unusually long internal service ownership descriptions." },
{ id: "svc-02", name: "build-coordinator", description: "Schedules preview builds" },
{ id: "svc-03", name: "browser-gateway", description: "Routes browser traffic" },
]
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds))
async function fakeSearch(query) {
// Shorter queries deliberately finish later.
await wait(query.length < 3 ? 800 : 100)
const normalized = query.trim().replace(/\s+/g, " ").toLowerCase()
return fixtures.filter((service) => service.name.includes(normalized))
}
function createLatestSearch(onResults, onError) {
let generation = 0
return async function search(query) {
const currentGeneration = ++generation
const parameters = new URLSearchParams({ query, include: "owner,repository,environment,lastDeployment,healthCheckSummary", sort: "relevance", direction: "descending" })
console.debug(`Searching /api/services?${parameters.toString()}`)
try {
const results = await fakeSearch(query)
if (currentGeneration !== generation) return
onResults(results)
} catch (error) {
if (currentGeneration !== generation) return
onError(error?.message ?? "Search failed without an error message")
}
}
}
const search = createLatestSearch(console.table, console.error)
void search("b")
void search("bill")
Compare the policies
| Strategy | Prevents stale updates? | Stops unnecessary network work? | Important limitation to document for the next person investigating a production incident |
|---|---|---|---|
| Generation counter | Yes | No | Old work continues, but its result is ignored when a newer search has started. |
AbortController plus cleanup guard |
Yes | Usually | Cancellation is cooperative; an aborted client request does not undo server-side work. |
| Debouncing alone | No | Reduces requests | Two requests can still overlap when the user pauses between keystrokes. |
| No | No | A fast local development server can hide the problem until release day. |
Give the UI the same ownership rule
This JSX example assumes a same-origin /api/services endpoint returning an array of { id, name, description } records. It is a rendering sample, not an endpoint added to this blog.
import { useEffect, useId, useState } from "react"
export function ServiceSearch() {
const inputId = useId()
const [query, setQuery] = useState("")
const [state, setState] = useState({ status: "idle", results: [] })
useEffect(() => {
const controller = new AbortController()
let active = true
if (!query.trim()) {
setState({ status: "idle", results: [] })
return () => {
active = false
controller.abort()
}
}
setState({ status: "loading", results: [] })
async function load() {
try {
const response = await fetch(`/api/services?${new URLSearchParams({ query, include: "owner,repository,environment,lastDeployment,healthCheckSummary" })}`, { signal: controller.signal })
if (!response.ok) throw new Error(`Search failed: ${response.status}`)
const results = await response.json()
if (active) setState({ status: "success", results })
} catch (error) {
if (active) setState({ status: "error", results: [], message: error instanceof Error ? error.message : "Search failed" })
}
}
void load()
return () => {
active = false
controller.abort()
}
}, [query])
return (
<section aria-label="Search services by name, repository, or the team responsible for maintaining their production deployment configuration">
<label htmlFor={inputId}>Find a service</label>
<input id={inputId} type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Try billing-worker" />
<p role="status">{state.status === "loading" ? "Searching…" : `${state.results.length} results`}</p>
{state.status === "error" && <p role="alert">{state.message}</p>}
<ul>
{state.results.map((service) => (
<li key={service.id}>
<strong>{service.name}</strong>
<p>{service.description}</p>
</li>
))}
</ul>
</section>
)
}
Things to verify before calling it fixed
- Start a slow request, then a fast request.
- Clear the input while a request is running.
- Navigate away before the response arrives.
- Test keyboard navigation and screen-reader announcements.
- Try a query with punctuation:
billing / preview + canary?.
A useful reference is MDN’s guide to AbortController. A long visible URL is a separate wrapping test: https://example.com/engineering/incidents/search-results-arriving-out-of-order?environment=preview&include=deployment-history-and-service-ownership&request=deliberately-long-example.
The smallest useful takeaway
Debouncing controls how often work starts. Cancellation controls whether work continues. Ownership controls which result may update the UI. Those are three different responsibilities.