Livewire v3 Performance: Lazy Hydration, Computed Properties, and Minimising Wire Payloads
#livewire #laravel #performance #frontend

Livewire v3 Performance: Lazy Hydration, Computed Properties, and Minimising Wire Payloads

4 min read Mohamed Said Mohamed Said

Why Livewire Payloads Balloon

Every Livewire network round-trip serialises your component's public state into JSON, ships it to the server, re-hydrates the component, runs the action, then returns a diff. The more public properties you expose — and the larger their values — the heavier that payload becomes. On a component with a paginated Eloquent collection stored as a public property, you can easily hit tens of kilobytes per interaction.

The fix is not to abandon Livewire; it is to be deliberate about what lives in public state versus what is derived on demand.

Computed Properties: Derive, Don't Store

The single biggest payload win is replacing stored query results with #[Computed] properties. Livewire v3 memoises them for the lifetime of a single request, so multiple template references cost one query.

use Livewire\Attributes\Computed;

class UserTable extends Component
{
    public string $search = '';
    public int $perPage = 25;

    #[Computed]
    public function users(): LengthAwarePaginator
    {
        return User::query()
            ->when($this->search, fn ($q) => $q->where('name', 'like', "%{$this->search}%"))
            ->paginate($this->perPage);
    }

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

The $users paginator never touches the wire payload. Only $search (a short string) and $perPage (an integer) are serialised. The query runs fresh on each hydration — which is exactly what you want for paginated, filterable data.

Persisting Computed Values Across Requests

For expensive aggregates that do not change on every interaction, add persist: true:

#[Computed(persist: true)]
public function roleSummary(): array
{
    return Role::withCount('users')->pluck('users_count', 'name')->all();
}

Livewire caches the return value in the session between requests. Use this sparingly — stale data is a real risk — and pair it with an explicit unset($this->roleSummary) call inside any action that mutates roles.

Lazy Hydration for Below-the-Fold Components

Nesting many Livewire components on a single page multiplies initial hydration cost. The #[Lazy] attribute defers a component's first render until the browser requests it:

use Livewire\Attributes\Lazy;

#[Lazy]
class RecentActivityFeed extends Component
{
    public function placeholder(): View
    {
        return view('livewire.placeholders.activity-skeleton');
    }

    public function render(): View
    {
        return view('livewire.recent-activity-feed');
    }
}

The parent page renders instantly; the browser fires a subsequent request for each lazy component. This trades one heavy synchronous render for several lighter async ones, which is almost always the right trade-off for dashboards.

Targeted Dirty-Tracking with #[Locked] and wire:model.live

Avoid wire:model.live on every input. It fires a network request on every keystroke. Reserve it for fields where immediate server feedback is genuinely needed (e.g., async validation). Use wire:model (deferred, syncs on next action) everywhere else.

For properties that should never be mutated from the client — IDs, tenant context, permission flags — add #[Locked]:

#[Locked]
public int $tenantId;

Livewire will throw a CorruptComponentPayloadException if the client attempts to overwrite it, preventing a class of mass-assignment-style vulnerabilities.

Trimming the Payload: Avoid Public Eloquent Models

Storing an entire Eloquent model as a public property serialises every attribute, including ones your template never reads. Prefer storing only the identifier and re-fetching via a computed property:

// Bad — full model in wire payload
public User $user;

// Good — only the ID travels over the wire
public int $userId;

#[Computed]
public function user(): User
{
    return User::findOrFail($this->userId);
}

For deeply nested forms, consider a DTO backed by Livewire's #[Validate] attribute rather than a raw array, so only the fields you declare are serialised.

Takeaways

  • Replace public query results with #[Computed] properties — they are memoised per request and never serialised.
  • Use persist: true on expensive, rarely-changing aggregates; invalidate explicitly.
  • Apply #[Lazy] to below-the-fold components to defer hydration cost.
  • Prefer wire:model (deferred) over wire:model.live unless real-time server feedback is required.
  • Mark immutable server-side properties with #[Locked] to prevent client tampering.
  • Store only scalar identifiers in public state; re-hydrate full models via computed properties.

Found this useful?

Frequently Asked Questions

2 questions
Q01 Does using #[Computed] mean the query runs on every single request, even when the data hasn't changed?
Yes, by default a computed property re-executes its method on every hydration cycle. That is intentional — it keeps the wire payload small and the data fresh. If the query is expensive and the data is stable, add persist: true to cache the result in the session across requests, and call unset($this->propertyName) in any action that invalidates it.
Q02 When should I use a nested Livewire component versus a Blade include for performance?
Use a nested Livewire component only when that section needs its own independent interactivity or state. A Blade include has zero wire overhead. Unnecessary nesting multiplies hydration cost; pair any nested component with #[Lazy] if it is not needed on initial paint.

Continue reading

More Articles

View all