AJAX Live Search

JA JASWANT Updated 19 Oct 2026
6 min read ·Lesson 113 of 143
In this post

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

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

  1. Terms shorter than 2 characters return no results without touching the database — a single letter would match almost everything.
  2. %, _, and `` in the term are escaped, so they are matched literally (see MySQL Where).
  3. The search matches the product name or the SKU. Each placeholder appears only once (:contains and :contains2), because native prepared statements do not allow reusing a named placeholder.
  4. Names starting with the term are listed first (CASE WHEN name LIKE 'term%' ...), which usually matches what the user is looking for.
  5. LIMIT keeps the response small; it is bound as an integer.
  6. 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

RequestResponse
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

  1. Debouncing: each keystroke restarts a 250 ms timer; a request is sent only when the user pauses.
  2. Cancelling: before a new request, controller.abort() cancels the previous one. Its fetch() rejects with an AbortError, which is deliberately ignored.
  3. Stale responses: even without cancellation, a slow response for "o" could arrive after the response for "ou". Comparing data.query with the current input discards outdated answers.
  4. 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.
  5. Keyboard support: arrow keys move through the suggestions, Enter opens the selected product, and Escape closes the list.
  6. Accessibility: role="combobox", role="listbox", aria-expanded, and aria-activedescendant tell 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

ConcernRecommendation
Many requestsDebounce, require at least 2 characters, cancel outdated requests
Slow LIKE '%term%' on large tablesUse MySQL FULLTEXT indexes, prefix searches on indexed columns, or a search engine (Meilisearch, Elasticsearch, OpenSearch)
Repeated popular queriesCache results briefly (for example, 60 seconds) in APCu or Redis
AbuseRate-limit the endpoint per IP address or session
Private dataSearch only data the current user may see; check permissions in the endpoint
Response sizeReturn 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

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Home PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else Slicing Strings User-defined Functions Slicing Strings PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning
Ask about this post
AI Ask about this post

Ask questions about AJAX Live Search and get answers drawn from it.

Signed-in readers only.