Instant Search Done Right: Debounce, Abort, Escape, Limit
Instant search is a deceptively complete exam of frontend-backend craft: typing "laravel" naively fires seven requests, the responses race each other back out of order, the % a user types becomes a wildcard, and an empty-string query scans the whole table. Here's the version that passes.
The client: debounce + abort
const input = document.querySelector('#search');
let timer, controller;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(runSearch, 250); // debounce: wait for a typing pause
});
async function runSearch() {
const q = input.value.trim();
if (q.length < 2) return render([]);
controller?.abort(); // kill the in-flight request
controller = new AbortController();
try {
const res = await fetch('/search.php?q=' + encodeURIComponent(q), {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(6000)]),
});
render(await res.json());
} catch (err) {
if (err.name !== 'AbortError') showSearchError();
}
}
The two mechanisms solve different problems and you need both: debouncing collapses keystrokes into one request per pause (250ms is the sweet spot - faster feels eager, slower feels laggy); aborting guarantees that when requests do overlap, a slow old response can never overwrite results for a newer query. Without the abort, "lar" arriving after "laravel" paints wrong results - the classic race that only shows up on bad wifi, i.e. in production. (Deeper dive: fetch cancellation patterns.)
The server: escape + bound
$q = mb_substr(trim($_GET['q'] ?? ''), 0, 60);
if (mb_strlen($q) < 2) exit(json_encode([]));
$stmt = $pdo->prepare(
"SELECT id, title, slug FROM posts
WHERE status = 'published' AND title LIKE ? ESCAPE '\\\\'
ORDER BY published_at DESC
LIMIT 8"
);
$stmt->execute(['%' . addcslashes($q, '%_\\') . '%']);
header('Content-Type: application/json');
echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC), JSON_UNESCAPED_UNICODE);
Every clause is one of the four failure modes: length floor and cap (no full-table scans for "a", no pathological 500-char patterns), wildcard escaping (a search for "100%" means the literal string), LIMIT 8 (dropdowns don't need page two), and the status filter so search can't see drafts.
The finishing details people notice
- Highlight safely: mark matches by building text nodes, or escape-then-wrap - innerHTML with user queries is self-inflicted XSS. On the server side, preg_replace_callback with preg_quote is the same idea.
- Show states: "searching..." during flight, "no results for X" on empty - silence reads as broken.
- Keyboard support: arrow keys + Enter through results; it's a combobox, and even a minimal role="listbox" treatment beats a mouse-only dropdown.
- Cache the last few queries client-side (a plain Map) - backspacing re-renders instantly without re-fetching.
Scale honesty: LIKE '%term%' can't use ordinary indexes, so past a few hundred thousand rows you move to FULLTEXT or a search engine - but the client-side contract above survives that migration untouched, which is exactly why it's worth getting right first.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.