AJAX Live Search
What Is Live Search? In simple terms: Live search shows matching results while the user types — like search suggestions on shopping sites. After a couple of letters, a small list appears below the sea
What Is Live Search?
In simple terms: Live search shows matching results while the user types — like search suggestions on shopping sites. After a couple of letters, a small list appears below the search box, and it updates as the user keeps typing. Users find what they need faster, often without ever opening a results page.
Technically: Live search combines several techniques: an input event listener, debouncing to avoid a request on every keystroke, AbortController to cancel outdated requests, a PHP endpoint that searches the database with escaped LIKE patterns and a strict result limit, and safe rendering of the results (including highlighting the matched text) with DOM methods. Keyboard navigation and ARIA attributes make the widget accessible.
The examples use the products table, the pdo() helper from PDO Connect, and jsonResponse() from AJAX PHP.
The Search Logic
<?php
// src/search-products.php
function searchProducts(PDO $pdo, string $term, int $limit = 5): array
{
$term = trim($term);
if (mb_strlen($term) < 2 || mb_strlen($term) > 50) {
return [];
}
$escaped = addcslashes($term, '%_\\');
$stmt = $pdo->prepare(
'SELECT sku, name, price
FROM products
WHERE is_active = 1 AND (name LIKE :contains OR sku LIKE :contains2)
ORDER BY CASE WHEN name LIKE :prefix THEN 0 ELSE 1 END, name
LIMIT :limit'
);
$stmt->bindValue(':contains', "%$escaped%");
$stmt->bindValue(':contains2', "%$escaped%");
$stmt->bindValue(':prefix', "$escaped%");
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return array_map(fn(array $row): array => [
'sku' => $row['sku'],
'name' => $row['name'],
'price_formatted' => '₹' . number_format((float) $row['price'], 2),
], $stmt->fetchAll());
}
How It Works
- Terms shorter than 2 characters return no results without touching the database — a single letter would match almost everything.
%,_, and `` in the term are escaped, so they are matched literally (see MySQL Where).- The search matches the product name or the SKU. Each placeholder appears only once (
:containsand:contains2), because native prepared statements do not allow reusing a named placeholder. - Names starting with the term are listed first (
CASE WHEN name LIKE 'term%' ...), which usually matches what the user is looking for. LIMITkeeps the response small; it is bound as an integer.- Only the fields needed for the suggestion list are returned.
The Endpoint: api/search.php
<?php
declare(strict_types=1);
require __DIR__ . '/../src/pdo.php';
require __DIR__ . '/../src/json-response.php';
require __DIR__ . '/../src/search-products.php';
$query = $_GET['q'] ?? '';
if (!is_string($query)) {
jsonResponse(400, ['error' => 'Invalid query.']);
}
jsonResponse(200, [
'query' => $query,
'results' => searchProducts(pdo(), $query),
]);
Responses
| Request | Response |
|---|---|
api/search.php?q=ou | {"query":"ou","results":[{"sku":"MOUSE-01","name":"Wireless Mouse","price_formatted":"₹599.00"}]} |
api/search.php?q=la | {"query":"la","results":[{"sku":"LAMP-01","name":"Desk Lamp","price_formatted":"₹1,299.00"}]} |
api/search.php?q=e | {"query":"e","results":[]} |
The endpoint echoes the query it answered. The JavaScript uses it to ignore responses that no longer match the text in the search box.
The Search Widget
<div class="search">
<label for="search">Search products</label>
<input id="search" type="search" autocomplete="off"
role="combobox" aria-expanded="false" aria-controls="suggestions">
<ul id="suggestions" role="listbox" hidden></ul>
<p id="search-status" aria-live="polite" class="visually-hidden"></p>
</div>
<script>
const input = document.getElementById('search');
const list = document.getElementById('suggestions');
const statusText = document.getElementById('search-status');
let debounceTimer;
let controller;
let activeIndex = -1;
function highlight(text, query) {
const fragment = document.createDocumentFragment();
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let position = 0;
let match;
while (lowerQuery !== '' && (match = lowerText.indexOf(lowerQuery, position)) !== -1) {
fragment.append(text.slice(position, match));
const mark = document.createElement('mark');
mark.textContent = text.slice(match, match + query.length);
fragment.append(mark);
position = match + query.length;
}
fragment.append(text.slice(position));
return fragment;
}
function render(results, query) {
activeIndex = -1;
list.replaceChildren(...results.map((product, index) => {
const li = document.createElement('li');
li.id = `suggestion-${index}`;
li.setAttribute('role', 'option');
li.append(highlight(product.name, query), ` — ${product.price_formatted}`);
li.addEventListener('click', () => {
window.location.href = `/products/${encodeURIComponent(product.sku)}`;
});
return li;
}));
const open = results.length > 0;
list.hidden = !open;
input.setAttribute('aria-expanded', String(open));
statusText.textContent = open ? `${results.length} suggestions` : (query.length >= 2 ? 'No matching products' : '');
}
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const query = input.value.trim();
if (query.length < 2) {
controller?.abort();
render([], query);
return;
}
debounceTimer = setTimeout(async () => {
controller?.abort(); // cancel the previous request
controller = new AbortController();
try {
const response = await fetch(`api/search.php?${new URLSearchParams({ q: query })}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data.query === input.value.trim()) { // ignore stale answers
render(data.results, data.query);
}
} catch (error) {
if (error.name !== 'AbortError') {
statusText.textContent = 'Search is temporarily unavailable.';
}
}
}, 250);
});
input.addEventListener('keydown', (event) => {
const items = list.querySelectorAll('li');
if (items.length === 0) {
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const step = event.key === 'ArrowDown' ? 1 : -1;
activeIndex = (activeIndex + step + items.length) % items.length;
items.forEach((item, i) => item.classList.toggle('active', i === activeIndex));
input.setAttribute('aria-activedescendant', items[activeIndex].id);
} else if (event.key === 'Enter' && activeIndex >= 0) {
event.preventDefault();
items[activeIndex].click();
} else if (event.key === 'Escape') {
render([], '');
}
});
</script>
Result in the Browser
Typing ou shows one suggestion, with the matched letters marked:
Wireless M[ou]se — ₹599.00
Step-by-Step Explanation
- Debouncing: each keystroke restarts a 250 ms timer; a request is sent only when the user pauses.
- Cancelling: before a new request,
controller.abort()cancels the previous one. Itsfetch()rejects with anAbortError, which is deliberately ignored. - Stale responses: even without cancellation, a slow response for "o" could arrive after the response for "ou". Comparing
data.querywith the current input discards outdated answers. - Safe highlighting:
highlight()builds text nodes and<mark>elements with DOM methods. The product name is never inserted as HTML, so a product called<img onerror=...>cannot execute code. - Keyboard support: arrow keys move through the suggestions, Enter opens the selected product, and Escape closes the list.
- Accessibility:
role="combobox",role="listbox",aria-expanded, andaria-activedescendanttell screen readers what is happening; the polite live region announces the number of suggestions.
The widget enhances a normal search form: pressing Enter without selecting a suggestion should still submit to a regular search results page.
Making Live Search Fast and Safe
| Concern | Recommendation |
|---|---|
| Many requests | Debounce, require at least 2 characters, cancel outdated requests |
Slow LIKE '%term%' on large tables | Use MySQL FULLTEXT indexes, prefix searches on indexed columns, or a search engine (Meilisearch, Elasticsearch, OpenSearch) |
| Repeated popular queries | Cache results briefly (for example, 60 seconds) in APCu or Redis |
| Abuse | Rate-limit the endpoint per IP address or session |
| Private data | Search only data the current user may see; check permissions in the endpoint |
| Response size | Return a handful of results with only the fields needed for display |
Common Mistakes
Sending a Request for Every Keystroke
Effect: Heavy server load and flickering results.
Fix: Debounce input and enforce a minimum length.
Showing Outdated Results
Effect: Results for an earlier term overwrite newer ones.
Fix: Abort previous requests and compare the answered query with the current input.
Highlighting With innerHTML
Effect: XSS through product names or search terms.
Fix: Build highlighted text with DOM methods.
Unescaped LIKE Wildcards
Effect: Searching for % or _ matches everything.
Fix: Escape them with addcslashes().
Mouse-Only Interaction
Effect: Keyboard and screen-reader users cannot use the suggestions.
Fix: Support arrow keys, Enter, and Escape, and add ARIA roles and states.
Best Practices
- Keep the search query logic in a testable PHP function with escaping, ordering, and limits.
- Debounce input, cancel stale requests, and ignore outdated responses.
- Render and highlight results safely with DOM methods.
- Make the widget accessible and keep a non-JavaScript search page as fallback.
- Use full-text indexes or a search engine, caching, and rate limiting as traffic grows.
Official reference: https://www.php.net/manual/en/reserved.variables.get.php