Skip to main content

Building Real-Time Search in Salesforce LWC: Apex, Wire Adapters & Debouncing

Updated Add a comment
In plain words: Real-time record search in Lightning Web Components (LWC) allows users to filter and display Salesforce data instantly as they type into an input field. By pairing reactive @wire adapters or imperative Apex calls with client-side debouncing, the component queries matching records without firing excessive server requests on every single keystroke.

Search bars and auto-complete lookup inputs are essential building blocks of modern enterprise Salesforce applications. Building a performant search feature requires balancing two competing priorities: giving the user an instant, reactive experience while protecting your Salesforce org from governor limit bottlenecks and unnecessary Apex execution loops.

1. Search Architecture: Wire Adapters vs. Imperative Apex

Salesforce developers generally choose between two approaches when querying data from an LWC:

  • Reactive Wire Service (@wire): Automatically provisions and caches search results whenever a reactive parameter (e.g., '$searchTerm') changes. Built-in client caching minimizes duplicate server roundtrips.
  • Imperative Apex Calls (searchRecords()): Invoked on demand inside an event handler or promise chain. Gives explicit programmatic control over loading spinners, error states, and execution timing.
  • Debounce Optimization: A crucial JavaScript timing pattern that delays sending the Apex query until the user stops typing for a designated interval (typically 300ms), eliminating redundant SOQL queries during rapid typing.
360 Real-Time Search Architecture Card:
  • Apex Configuration: @AuraEnabled(cacheable=true) with WITH USER_MODE.
  • SOQL Safeguard: Bind variable with wildcards (LIKE :key) and strict LIMIT clauses.
  • Frontend Optimization: window.clearTimeout() debounce mechanism.
  • UI Component: <lightning-input type="search"> with clear button support.

2. Step-by-Step Implementation

Below is a production-grade implementation combining a secure, user-mode Apex controller with a debounced, reactive LWC search interface.

Step 1: Create the Apex Search Controller (RecordSearchController.cls)
Sanitize input values, enforce user-mode security, and apply query limits.
public with sharing class RecordSearchController {

    @AuraEnabled(cacheable=true)
    public static List<Account> searchAccounts(String searchKey) {
        if (String.isBlank(searchKey) || searchKey.trim().length() < 2) {
            return new List<Account>();
        }

        // Format wildcard search key safely
        String formattedKey = '%' + searchKey.trim() + '%';

        return [
            SELECT Id, Name, Industry, Phone, Type
            FROM Account
            WHERE Name LIKE :formattedKey
            WITH USER_MODE
            ORDER BY Name ASC
            LIMIT 20
        ];
    }
}
Step 2: Build the LWC Template (accountSearch.html)
Use SLDS search inputs, loading indicators, and clean list layouts.
<template>
    <lightning-card title="Account Quick Search" icon-name="standard:account">
        <div class="slds-p-around_medium">
            <!-- Search Input with Debounce -->
            <lightning-input
                type="search"
                label="Search Accounts"
                placeholder="Type at least 2 characters..."
                value={searchKey}
                onchange={handleKeyChange}>
            </lightning-input>

            <!-- Loading Spinner -->
            <template lwc:if={isLoading}>
                <div class="slds-is-relative slds-m-vertical_large">
                    <lightning-spinner alternative-text="Searching..." size="small"></lightning-spinner>
                </div>
            </template>

            <!-- Results List -->
            <template lwc:if={hasResults}>
                <ul class="slds-has-dividers_bottom-space slds-m-top_medium">
                    <template for:each={accounts} for:item="acc">
                        <li key={acc.Id} class="slds-item slds-p-vertical_x-small">
                            <div class="slds-grid slds-grid_align-spread">
                                <div>
                                    <p class="slds-text-heading_small"><strong>{acc.Name}</strong></p>
                                    <p class="slds-text-body_small slds-text-color_weak">
                                        Industry: {acc.Industry} &bull; Phone: {acc.Phone}
                                    </p>
                                </div>
                                <div class="slds-badge slds-theme_light">{acc.Type}</div>
                            </div>
                        </li>
                    </template>
                </ul>
            </template>

            <!-- Empty Results State -->
            <template lwc:elseif={noResultsFound}>
                <div class="slds-text-align_center slds-m-top_medium slds-text-color_weak">
                    No accounts found matching "{searchKey}".
                </div>
            </template>
        </div>
    </lightning-card>
</template>
Step 3: Implement the JavaScript Controller (accountSearch.js)
Apply debouncing and wire the reactive parameter.
import { LightningElement, wire, track } from 'lwc';
import searchAccounts from '@salesforce/apex/RecordSearchController.searchAccounts';

const DEBOUNCE_DELAY = 300; // milliseconds

export default class AccountSearch extends LightningElement {
    searchKey = '';
    debouncedSearchKey = '';
    debounceTimer;
    isLoading = false;
    accounts = [];
    error;

    // Reactive wire adapter automatically fetches when debouncedSearchKey updates
    @wire(searchAccounts, { searchKey: '$debouncedSearchKey' })
    wiredAccounts({ error, data }) {
        this.isLoading = false;
        if (data) {
            this.accounts = data;
            this.error = undefined;
        } else if (error) {
            this.error = error;
            this.accounts = [];
            console.error('Search error:', error);
        }
    }

    get hasResults() {
        return this.accounts && this.accounts.length > 0;
    }

    get noResultsFound() {
        return !this.isLoading && 
               this.debouncedSearchKey.trim().length >= 2 && 
               (!this.accounts || this.accounts.length === 0);
    }

    handleKeyChange(event) {
        this.searchKey = event.target.value;
        this.isLoading = true;

        // Reset the timer on every keystroke (Debounce)
        window.clearTimeout(this.debounceTimer);

        this.debounceTimer = window.setTimeout(() => {
            this.debouncedSearchKey = this.searchKey;
            if (this.debouncedSearchKey.trim().length < 2) {
                this.isLoading = false;
                this.accounts = [];
            }
        }, DEBOUNCE_DELAY);
    }
}
Step 4: Configure Metadata XML (accountSearch.js-meta.xml)
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>60.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__AppPage</target>
        <target>lightning__RecordPage</target>
        <target>lightning__HomePage</target>
        <target>lightningCommunity__Page</target>
    </targets>
</LightningComponentBundle>

3. Common Traps & Performance Best Practices

Governor Limit Trap: Firing SOQL Queries on Every Keystroke Without Debouncing
Calling an imperative Apex method on every raw onchange event triggers an individual server roundtrip for every single character typed. If a user quickly types a 10-character name, the component fires 10 simultaneous Apex transactions. This leads to governor limit exhaustion and race conditions where older requests overwrite newer search results. Always wrap real-time search inputs in a 300ms debounce timer.
SOQL Injection Trap: Using Unsanitized Dynamic String Concatenation
Writing raw dynamic queries such as Database.query('SELECT Id FROM Account WHERE Name LIKE \'%' + key + '%\'') opens security vulnerabilities. Always use static SOQL with parameterized bind variables (WHERE Name LIKE :formattedKey) and append WITH USER_MODE to respect Field-Level Security and sharing rules.
Core Rule: Enforce a minimum 2-character threshold before querying, debounce user input by 300ms in JavaScript, and mark Apex search methods as @AuraEnabled(cacheable=true) to leverage client-side caching.
  • Add a Clear Button: Using <lightning-input type="search"> automatically provides a built-in clear ("x") icon that wipes the input and clears results seamlessly.
  • Apply Sensible Limits: Always include a LIMIT 20 or LIMIT 50 clause in search queries to keep query execution times low and memory consumption minimal.
  • Handle Empty States: Clearly distinguish between initial idle states (user hasn't typed yet), loading states (spinner active), and zero-result states to maintain a smooth user experience.

Summary

Implementing real-time search in Lightning Web Components provides a seamless, dynamic user experience across Salesforce apps. By combining cacheable Apex methods, user-mode query security, and client-side debounce timers, developers can deliver fast, responsive search tools that protect platform resources and scale effortlessly.

Was this article helpful?

Have a question or spotted a mistake? Ask here about this article

Found this guide useful? Buy me a coffee (opens in a new tab)

Comments

Questions, corrections and your own experience are all welcome.