Skip to content
Carl Victor Fontanos
Carl Victor Fontanos

Carl Victor Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

Instant Search Done Right: Debounce, Abort, Escape, Limit

C
Carlo Fontanos
· 3 min read

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.

C
Written by Carlo Fontanos

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.

Message sent!

Your details are only used to reply to you.

Keep reading