Livewire v3 Performance: Reducing Round-Trips with Computed Properties and Batched Updates
#livewire #laravel #performance #frontend

Livewire v3 Performance: Reducing Round-Trips with Computed Properties and Batched Updates

3 min read Mohamed Said Mohamed Said

The Hidden Cost of Naive Livewire Components

Every wire:model binding without a modifier fires a full server round-trip on each keystroke. In a component with a search field, three filter dropdowns, and a paginated table, that's a network request per character typed. On a busy server this compounds quickly.

Livewire v3 ships with the tools to fix this — but they require deliberate use.


Computed Properties: Memoised, Lazy, and Cache-Aware

The #[Computed] attribute turns a method into a property that is evaluated once per render cycle and memoised for the lifetime of that request.

use Livewire\Attributes\Computed;

class ProductSearch extends Component
{
    public string $query = '';
    public string $category = '';

    #[Computed]
    public function products(): LengthAwarePaginator
    {
        return Product::query()
            ->when($this->query, fn ($q) => $q->search($this->query))
            ->when($this->category, fn ($q) => $q->where('category_id', $this->category))
            ->paginate(20);
    }

    public function render(): View
    {
        return view('livewire.product-search');
    }
}

In the Blade template you access it as $this->products — Livewire calls the method once and caches the result for that render. No accidental double-query from calling $this->products in both a conditional and a loop.

Persisting Computed Values Across Requests

For expensive aggregates that don't change per keystroke, add a cache TTL:

#[Computed(cache: true, seconds: 60)]
public function categoryCounts(): Collection
{
    return Product::query()
        ->selectRaw('category_id, count(*) as total')
        ->groupBy('category_id')
        ->pluck('total', 'category_id');
}

The result is stored in the Laravel cache keyed by component ID and method name. Invalidate it explicitly with unset($this->categoryCounts) when a mutation occurs.


Debouncing and Lazy Bindings

Never bind a search input with plain wire:model. Use wire:model.live.debounce.400ms to wait for the user to pause:

<input
    wire:model.live.debounce.400ms="query"
    type="text"
    placeholder="Search products…"
/>

For fields where you only care about the final value (a select, a checkbox), wire:model.blur fires exactly one request when focus leaves the element:

<select wire:model.blur="category">
    @foreach($categories as $id => $name)
        <option value="{{ $id }}">{{ $name }}</option>
    @endforeach
</select>

Combining .live.debounce on text inputs with .blur on selects already eliminates the majority of superfluous requests in a typical filter UI.


Batching Multiple Property Updates

When Alpine.js or a custom JS event needs to update several Livewire properties at once, avoid firing one request per property. Use $wire.set inside $wire.call — or better, batch them with the JS API:

// In an Alpine component or inline script
AsyncFunction: async () => {
    await $wire.setMultiple({
        query: '',
        category: '',
        page: 1,
    });
}

On the PHP side, expose a single action that accepts all reset values:

public function resetFilters(): void
{
    $this->query    = '';
    $this->category = '';
    $this->resetPage();
}

One method call, one HTTP request, one re-render. This is always preferable to three sequential $wire.set() calls.


Skipping Re-Renders with #[Locked] and #[Renderless]

Not every action needs a full re-render. Mark actions that only mutate server state (e.g., toggling a favourite) with #[Renderless]:

use Livewire\Attributes\Renderless;

#[Renderless]
public function toggleFavourite(int $productId): void
{
    auth()->user()->favourites()->toggle($productId);
}

The round-trip still happens, but Livewire skips diffing and patching the DOM entirely — a meaningful saving when the component renders a large table.


Key Takeaways

  • Use #[Computed] to memoize expensive queries per render; add cache: true for cross-request persistence.
  • Replace wire:model with wire:model.live.debounce.400ms on text inputs and wire:model.blur on selects.
  • Consolidate multi-property resets into a single PHP action to avoid stacked round-trips.
  • Mark fire-and-forget mutations with #[Renderless] to skip DOM diffing.
  • Profile with browser DevTools Network tab first — count requests before optimising.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does `#[Computed(cache: true)]` share the cached value across different users?
No. Livewire keys the cache entry by component ID, which is unique per user session. Different users get separate cache entries, so there is no data leakage between sessions.
Q02 When should I use `wire:model.blur` versus `wire:model.live.debounce`?
Use `.blur` for inputs where intermediate values are meaningless (selects, checkboxes, date pickers). Use `.live.debounce` for text inputs where you want near-real-time feedback but still want to wait for the user to pause typing.
Q03 Can `#[Renderless]` actions still return data to the frontend?
Yes. You can dispatch browser events or use `$this->dispatch()` inside a renderless action. Alpine listeners on the page can react to those events without a DOM diff ever occurring.

Continue reading

More Articles

View all