Debouncing URL Updates While Typing
After reading this you will be able to keep a search term in the URL without producing a history entry per keystroke, a request per keystroke, or an input that feels laggy — by debouncing the right thing and committing on the right event.
← Back to Query String State Sync
Prerequisites
Core Concept
Three separate things happen when someone types in a search box, and they want three different rhythms.
The input’s own value must update on every keystroke. Anything else makes typing feel broken — characters appear late, the caret jumps, and fast typists lose input. This is never debounced.
The URL should update once the user pauses. Writing on every keystroke either floods the history stack (with pushState) or performs a needless history operation per character (with replaceState), and it makes the address bar flicker. Around 250–300 ms after the last keystroke is the sweet spot: long enough to skip intermediate states, short enough that a user who pauses to read has an accurate URL to copy.
The search request wants its own rhythm again, usually a slightly longer debounce than the URL plus cancellation of in-flight requests. Coupling it to the URL write is convenient but not required, and separating them lets you write the URL sooner than you issue an expensive query.
The other half of the design is which history operation to use. Typing is a refinement, so intermediate states use replaceState — one entry, updated in place, no back-button pollution. Committing the search is a destination, so pressing Enter or clicking a result uses pushState, giving the user exactly one Back step to return to the previous results.
Implementation
// TypeScript 5.x — a debounce that can be flushed and cancelled, no dependencies
interface Debounced<A extends unknown[]> {
(...args: A): void;
/** Run the pending call immediately — used on submit and on unmount. */
flush(): void;
cancel(): void;
}
export function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number): Debounced<A> {
let timer: number | undefined;
let pending: A | null = null;
const wrapped = ((...args: A) => {
pending = args;
window.clearTimeout(timer);
timer = window.setTimeout(() => {
timer = undefined;
const call = pending;
pending = null;
if (call) fn(...call);
}, ms);
}) as Debounced<A>;
wrapped.flush = () => {
if (timer === undefined) return;
window.clearTimeout(timer);
timer = undefined;
const call = pending;
pending = null;
if (call) fn(...call);
};
wrapped.cancel = () => {
window.clearTimeout(timer);
timer = undefined;
pending = null;
};
return wrapped;
}
The URL writer merges as usual and uses replaceState for intermediate states:
// TypeScript 5.x — write the query, preserving every other parameter
function writeQuery(q: string, mode: "push" | "replace"): void {
const params = new URLSearchParams(location.search);
if (q.trim() === "") params.delete("q");
else params.set("q", q.trim());
params.delete("page"); // a new query always starts at page one
params.sort();
const query = params.toString();
const url = `${location.pathname}${query ? `?${query}` : ""}`;
if (url === location.pathname + location.search) return;
if (mode === "push") history.pushState(history.state, "", url);
else history.replaceState(history.state, "", url);
}
const writeQueryDebounced = debounce((q: string) => writeQuery(q, "replace"), 250);
The search itself gets its own debounce and cancels the request it supersedes:
// TypeScript 5.x — one in-flight request at a time
let inFlight: AbortController | null = null;
const runSearch = debounce(async (q: string) => {
inFlight?.abort(); // supersede, do not race
const controller = new AbortController();
inFlight = controller;
try {
const results = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
}).then((r) => r.json());
renderResults(results);
} catch (error) {
if ((error as Error).name !== "AbortError") showSearchError();
} finally {
if (inFlight === controller) inFlight = null;
}
}, 400);
Wiring the input keeps the three rhythms separate, and commits on the events that mean “I meant this”:
// TypeScript 5.x
export function bindSearchInput(input: HTMLInputElement, form: HTMLFormElement): () => void {
const onInput = () => {
// 1. The visible value is already updated by the browser — do nothing here.
// 2. The URL, after a pause.
writeQueryDebounced(input.value);
// 3. The request, after a slightly longer pause.
runSearch(input.value);
};
const onSubmit = (event: SubmitEvent) => {
event.preventDefault();
// Committing: flush the pending work, then push a real entry.
writeQueryDebounced.cancel();
runSearch.flush();
writeQuery(input.value, "push");
};
// Leaving the field is also a commit-ish moment: make the URL accurate NOW,
// so a user who tabs away and copies the address bar gets what they see.
const onBlur = () => writeQueryDebounced.flush();
input.addEventListener("input", onInput);
input.addEventListener("blur", onBlur);
form.addEventListener("submit", onSubmit);
return () => {
writeQueryDebounced.flush(); // never leave the URL describing a stale view
runSearch.cancel();
input.removeEventListener("input", onInput);
input.removeEventListener("blur", onBlur);
form.removeEventListener("submit", onSubmit);
};
}
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("typing produces one URL update and no history entries", async ({ page }) => {
await page.goto("/search");
const depth = await page.evaluate(() => history.length);
await page.getByRole("searchbox").type("mechanical keyboard", { delay: 40 });
await page.waitForTimeout(400);
await expect(page).toHaveURL(/q=mechanical\+keyboard/);
expect(await page.evaluate(() => history.length)).toBe(depth); // replaceState only
});
test("submitting commits a single history entry", async ({ page }) => {
await page.goto("/search");
await page.getByRole("searchbox").fill("laptop");
await page.waitForTimeout(400);
const depth = await page.evaluate(() => history.length);
await page.getByRole("searchbox").press("Enter");
expect(await page.evaluate(() => history.length)).toBe(depth + 1);
await page.goBack();
await expect(page).not.toHaveURL(/q=laptop/);
});
test("blurring makes the URL accurate immediately", async ({ page }) => {
await page.goto("/search");
await page.getByRole("searchbox").fill("monitor");
await page.getByRole("searchbox").blur(); // no wait — the flush is synchronous
await expect(page).toHaveURL(/q=monitor/);
});
Manually, type a long phrase quickly and watch history.length in the console. It must not move. Then press Enter: it should increase by exactly one, and a single Back press should return you to the pre-search state.
Gotchas
- Debouncing the input value makes typing feel broken. Only the side effects are debounced; the field itself is always immediate.
- A debounce without a flush leaves the URL stale. If the user copies the address bar during the pause, they share a URL for a search they did not run.
- Timers outlive components. Cancel on unmount, or a pending write fires after the user has navigated elsewhere and rewrites the URL of a different page.
pushStateper keystroke is the classic history flood. Twenty characters means twenty Back presses to escape.- Aborted fetches reject. Filter
AbortErrorout of your error handling, or every superseded search shows an error toast. - Announcing on every URL change is unusable with a search box. Route announcements should fire on route identity change, not on
replaceStatewrites — see route change accessibility. - Trim before comparing. A trailing space produces a URL that differs from the trimmed one, which defeats the no-op guard and writes on every space.
FAQ
What is the right debounce delay? 250–300 ms for the URL and 350–500 ms for a network request. Below 150 ms you gain nothing over writing immediately; above about 600 ms the URL feels detached from the screen.
Should I use throttle instead? No. Throttling writes at a fixed rate during typing, which is exactly the flooding you are trying to avoid. Debounce fires once after the activity stops, which is the desired semantics.
Does replaceState on every pause hurt performance? It is cheap — a fraction of a millisecond — but it is not free, and doing it per keystroke on a low-end device is measurable alongside rendering. The debounce is worth having regardless.
What if the user navigates away mid-debounce? Flush first, then cancel. Flushing keeps the URL honest for the entry being left; cancelling prevents a later write against the new page.
How does this interact with an empty query? Delete the key rather than writing q=. An empty parameter and an absent one should not be two different states.
Related
- Query String State Sync — the write policy and merge rules this technique applies.
- Syncing Filters to the URL with URLSearchParams — the non-typed controls that share the same query string.
- pushState vs replaceState: When to Use Each — the decision that separates typing from committing.