Build a Debounced Live Search Input in Livewire 4 Without Hammering Your Database

Livewire debounce search done right: tune wire:model.live.debounce, reset pagination, guard short terms and index the column so typing never lags behind.

Steven Richardson
Steven Richardson
· 11 min read

A live search input is the first thing most people build in Livewire and the first thing to fall over in production. On a seeded table, wire:model.live="search" feels instant. On two hundred thousand rows it lags a word behind the user, and the database sees a burst of full table scans for every search.

Then the secondary bugs arrive. An empty results page because pagination was never reset. A spinner that strobes on every fast response. A user who types % and gets the entire table back.

Here is the whole pattern, end to end, on Livewire 4 — with the indexing step most tutorials leave out.

Create the search component and bind the input#

Start with the smallest thing that works: a single-file component with one public property and one query. Livewire 4 components live in resources/views/components/ with the prefix, and the PHP block sits above the markup in the same file. Note the $this-> prefix on the method call in the template — that is how you reach component methods from a v4 view.

{{-- resources/views/components/⚡search-users.blade.php --}}
<?php

use App\Models\User;
use Livewire\Attributes\Url;
use Livewire\Component;

new class extends Component
{
    #[Url] // Keeps the term in the query string so a search is shareable.
    public string $search = '';

    public function results()
    {
        return User::query()
            ->where('name', 'like', "%{$this->search}%")
            ->get();
    }
};
?>

<div>
    <input type="search" wire:model.live="search" placeholder="Search users…">

    <ul>
        @foreach ($this->results() as $user)
            <li wire:key="user-{{ $user->id }}">{{ $user->name }}</li>
        @endforeach
    </ul>
</div>

That #[Url] attribute is doing real work for one line of code: the term survives a refresh and the URL becomes shareable. I've written about the #[Url] attribute and query-string filters in more depth, including how it interacts with history and keep.

The wire:key on the loop is not optional. Without it, Livewire's morph engine reuses the existing <li> nodes as the result set changes underneath them, and you get rows showing the wrong data.

Open the network tab and type a name. Every request there is a full round trip: state serialisation, framework boot, an unindexed LIKE scan, then a DOM diff. That is what we are about to reduce.

Raise the debounce window to fit your dataset#

Add .debounce.400ms to the binding. This is the single highest-value change in the whole article, and it is also where most advice online is out of date.

<input type="search" wire:model.live.debounce.400ms="search" placeholder="Search users…">

wire:model.live on a text input already debounces at 150ms — that has been true since Livewire 3, but plenty of tutorials still describe the Livewire 2 behaviour where every keystroke fired immediately. So you are not adding debouncing here. You are tuning it.

How the windows feel in practice:

Window Feel Use when
150ms (default) Reacts mid-word Cheap queries on small, indexed tables
300ms Reacts at the end of a word Most indexed searches
400ms Reacts on a natural pause Joins, LIKE '%x%', anything over ~50ms
750ms+ Feels deliberate Expensive aggregates or an external API

Tune to the cost of your query, not to taste. If the query takes 120ms, a 150ms debounce guarantees requests overlap while the user is still typing.

Two modifiers get confused with each other constantly. .debounce waits for a pause in typing and then fires once. .throttle fires at most once per interval regardless of whether the user has stopped. Search wants debounce — throttle will keep firing mid-word and gives you the request flood you were trying to avoid.

Livewire 4 also added .enter, which is worth knowing about because sometimes live search is the wrong answer entirely. If your query is genuinely expensive, wire:model.enter.live="search" — or .blur.live — gives the user an explicit trigger and your database a break.

Move the query into a computed property#

Swap the plain method for a #[Computed] one. Livewire caches a computed property for the duration of the request, so referencing it in three places in your Blade template runs the query once rather than three times.

<?php

use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;

new class extends Component
{
    #[Url]
    public string $search = '';

    #[Computed]
    public function results(): Collection
    {
        return User::query()
            ->where('name', 'like', "%{$this->search}%")
            ->get();
    }
};
?>

In the template, drop the parentheses — $this->results, not $this->results(). That is what routes the access through Livewire's cache.

One trap: do not reach for #[Computed(persist: true)] here. Persisted computed properties cache across requests for an hour by default, which means the cached result set survives the search term changing and your input appears to do nothing. Persistence is for values that don't depend on volatile state — I cover where it does belong in caching heavy queries with persisted computed properties.

Reset pagination when the search term changes#

Add the WithPagination trait, paginate the results, and hook the property update to reset the page. Skip this step and you get the classic bug report: "search returns nothing", from a user who was on page 4 when they started typing.

<?php

use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;

new class extends Component
{
    use WithPagination;

    #[Url]
    public string $search = '';

    /** Fires after $search is updated, before the component re-renders. */
    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    #[Computed]
    public function results(): LengthAwarePaginator
    {
        return User::query()
            ->where('name', 'like', "%{$this->search}%")
            ->orderBy('name')
            ->paginate(15);
    }
};
?>

<div>
    <input type="search" wire:model.live.debounce.400ms="search" placeholder="Search users…">

    <ul>
        @foreach ($this->results as $user)
            <li wire:key="user-{{ $user->id }}">{{ $user->name }}</li>
        @endforeach
    </ul>

    {{ $this->results->links() }}
</div>

The hook name is updated plus the studly property name, so $search becomes updatedSearch(). It runs on every update to that property and not at mount, which is exactly what you want — a component booting on page 3 from the URL keeps page 3.

If a paginator feels wrong for search results, wire:intersect for infinite scroll pagination is the alternative, and it needs the same page reset on term change.

Guard against empty and very short terms#

Return an empty paginator below a minimum term length instead of querying. An empty search box should cost zero database time, and a single character matches so much of the table that the result is useless to the user and expensive for you.

private const MIN_LENGTH = 2;

private const PER_PAGE = 15;

#[Computed]
public function results(): LengthAwarePaginator
{
    $term = trim($this->search);

    if (mb_strlen($term) < self::MIN_LENGTH) {
        // Same interface as a real paginator, so ->links() still renders. No query.
        return new LengthAwarePaginator([], 0, self::PER_PAGE);
    }

    return User::query()
        ->where('name', 'like', self::escapeLike($term).'%')
        ->orderBy('name')
        ->paginate(self::PER_PAGE);
}

/** Escapes LIKE wildcards so a user typing "%" doesn't match every row. */
private static function escapeLike(string $term): string
{
    return addcslashes($term, '%_\\');
}

Two things worth calling out. new LengthAwarePaginator([], 0, self::PER_PAGE) gives the template the same object shape without touching the database — the Illuminate\Pagination\LengthAwarePaginator concrete class, imported alongside the contract used for the return type.

The escaping matters more than it looks. % and _ are wildcards inside a LIKE pattern, so a user typing a single % matches every row in the table. That is not SQL injection — bindings still protect you — but it is a trivial way to make your search do a full scan on demand.

I've also dropped the leading wildcard: term% rather than %term%. That one change is what makes the next step work.

Add a delayed, targeted loading state#

Wrap the results in a loading state that is both delayed and scoped. Delayed, so a 40ms response never flashes a spinner; scoped, so clicking a pagination link doesn't light up the search indicator too.

<div>
    <input type="search" wire:model.live.debounce.400ms="search" placeholder="Search users…">

    {{-- Only shows if the request outlives 200ms, and only for the search property. --}}
    <div wire:loading.delay wire:target="search">
        Searching…
    </div>

    <ul wire:loading.class.delay="opacity-50" wire:target="search">
        @foreach ($this->results as $user)
            <li wire:key="user-{{ $user->id }}">{{ $user->name }}</li>
        @endforeach
    </ul>

    {{ $this->results->links() }}
</div>

wire:target accepts property names as well as action names, so wire:target="search" matches exactly the requests this input generates. Bare wire:loading would fire on every request the component makes.

The delay aliases are worth memorising, because the bare .delay is 200ms: .delay.shortest is 50ms, .shorter 100ms, .short 150ms, .long 300ms, .longer 500ms, .longest 1000ms. For a 400ms debounce, the default 200ms delay is about right — anything faster and the spinner appears for responses the user never noticed waiting for.

Livewire 4 added a lighter alternative. Every element that triggers a request now gets a data-loading attribute automatically, so you can style loading states in Tailwind with no directives and no wire:target at all:

<input
    type="search"
    wire:model.live.debounce.400ms="search"
    class="data-loading:opacity-50"
>

<ul class="has-data-loading:opacity-50">
    {{-- ... --}}
</ul>

I still reach for wire:loading when I need to show and hide distinct elements, and data-loading when I just want to dim something. For results that take long enough to need real structure rather than a spinner, @placeholder skeleton loaders are the better shape.

Add a database index and measure the difference#

Index the column you are searching. Everything above reduces the number of queries; this is the step that makes each remaining query cheap, and it's the one most search tutorials skip entirely.

php artisan make:migration add_name_index_to_users_table
public function up(): void
{
    Schema::table('users', function (Blueprint $table): void {
        $table->index('name');
    });
}

Here is the part that justifies dropping the leading wildcard earlier. A B-tree index is ordered, so LIKE 'ali%' can seek straight to the matching range. LIKE '%ali%' cannot — the engine has no idea where to start, so it reads every row. On a 200k-row table on my machine that was 180ms versus 2ms, and the gap widens with the row count.

Confirm it rather than trusting me. Run EXPLAIN on both and look at the access type:

EXPLAIN SELECT * FROM users WHERE name LIKE 'ali%';   -- type: range, key: users_name_index
EXPLAIN SELECT * FROM users WHERE name LIKE '%ali%';  -- type: ALL, key: NULL

If you genuinely need infix or fuzzy matching, stop bending LIKE into shape. That is what full-text indexes and a dedicated search engine are for — Laravel Scout with Typesense for typo-tolerant search covers that ground properly. And if your result rows render relationships, enable Eloquent strict mode to catch the N+1 queries that a search result list attracts.

Test the debounced search with Pest#

Assert the behaviour, not the timing. The debounce lives in the browser, so a component test can't observe it — what it can prove is that the right rows come back, that short terms return nothing, and that the page resets.

use App\Models\User;
use Livewire\Livewire;

it('filters users by the search term', function (): void {
    User::factory()->create(['name' => 'Alice Chen']);
    User::factory()->create(['name' => 'Bob Marsden']);

    Livewire::test('search-users')
        ->set('search', 'ali')
        ->assertSee('Alice Chen')
        ->assertDontSee('Bob Marsden');
});

it('returns nothing for a term below the minimum length', function (): void {
    User::factory()->create(['name' => 'Alice Chen']);

    Livewire::test('search-users')
        ->set('search', 'a')
        ->assertDontSee('Alice Chen');
});

it('resets pagination when the search term changes', function (): void {
    User::factory()->count(40)->create();

    Livewire::test('search-users')
        ->set('paginators.page', 3)
        ->set('search', 'zz')
        ->assertSet('paginators.page', 1);
});

it('treats a wildcard character as a literal', function (): void {
    User::factory()->create(['name' => 'Alice Chen']);

    Livewire::test('search-users')
        ->set('search', '%%')
        ->assertDontSee('Alice Chen');
});

Single-file components are addressed by name, so Livewire::test('search-users') resolves ⚡search-users.blade.php. That last test is the one I'd keep above all the others — it's the assertion that stops someone "simplifying" the escaping away in six months. For more on structuring these, see testing Livewire components with Pest.

FAQ#

How do I debounce an input in Livewire?

Append .debounce.Xms to a live binding: wire:model.live.debounce.400ms="search". Livewire waits for the user to stop typing for that long before sending a request. For search, 300–400ms is usually right; tune it up if your query is expensive.

What is the default debounce on wire:model.live?

150 milliseconds on text inputs. Livewire adds it automatically, so wire:model.live is already debounced — anything you write with .debounce is overriding that default, not introducing debouncing. Advice describing a request per keystroke is describing Livewire 2.

What is the difference between debounce and throttle in Livewire?

Debounce waits for a pause and then fires once, so continuous typing produces a single request. Throttle fires at most once per interval regardless of whether the user has stopped, so it keeps sending requests mid-word. Search wants debounce; throttle suits things that need guaranteed periodic updates, like a progress readout.

Why does my Livewire search show an empty page after typing?

Because the paginator is still on the page the user was viewing before the term changed, and the new result set has fewer pages. Add an updatedSearch() hook that calls $this->resetPage(). Livewire runs updated{Property} hooks after the property changes but not at mount, so a page number restored from the URL is left alone.

How do I show a loading spinner only while searching?

Scope it with wire:target and delay it: <div wire:loading.delay wire:target="search">. The target restricts the indicator to requests generated by that property, and the delay means it only appears if the request outlives 200ms. In Livewire 4 you can also style the automatic data-loading attribute with Tailwind and skip the directives.

How do I stop Livewire firing a request on every keystroke?

Raise the debounce window, or take the input off live binding entirely. wire:model.blur.live sends one request when the user leaves the field and wire:model.enter.live sends one when they press Enter. For a genuinely expensive query, an explicit trigger beats any debounce value.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.