Laravel Livewire Performance Tips & Tricks

Updated:

I'm the managing director of Pixel, and we use Laravel + Livewire on almost every client build we do, and we've done a lot. It's safe to say we have had our fair share of pain from Livewiere, and now I'm here to share that with you.

Livewire is a magical tool that can transform a developers life in an instant. It let's you create single page components with ease, that bring insane react/vue like functionality.

But out of the box, (Livewire v2) this can be a real gotcha. V3 is soon to be released, and does fix some of these issues, by changing the default way the library works.

This post was originally written for Livewire v2, but with v3 and the announcement that Livewire would become a first-party vendor package, Caleb reworked the defaults for Livewire, which has now made it a lot more performant out of the box.

I've updated this post to be relevant for v3, while still remaining very relevant for v2, if for some reason, you are still stuck on that version.

What's new in Livewire v3?

Brand New Core

The core code powering Laravel Livewire was completely rewritten with v3, which saw many features have to be re-added in new ways. This meant that many applications could not successfully upgrade to Livewire v3 for quite some time, but now all Livewire v2 features have properly been re-added back into the core for v3, so it's completely safe to update now.

Alpine, Now In The Core

Alpine is now included in the core and able to be used by your application, with just Laravel Livewire being installed. This means that now Livewire can use some of features that Alpine offer to reduce the load on the network when doing things like showing/hide loading elements, and storing dirty data.

You can still absolustely override Livewire's initialization of Alpine.

Awesome Nesting

Caleb as part of v3 wanted to improve the nestability of Livewire components, as previously, it was a bit hit and miss as to whether you could execute nesting without a bunch of issues.

Thanks to reactive props and bundled requests, this is now a dream.

You now even have access to a new $parent property which lets you access the parent Livewire component. Nifty!

Bundled Requests

This is a big deal. As you'll see below, you could previously fire up to 1,000 API requests a minute, just by filling out some form elements out of the box. This is what led to many performance issues of Livewire if not used and configured properly, as you'd run into the old trap of state being out of date and Livewire replacing your new values with old ones.

Absolutely awesome that this is now much more optimised.

Deferred By Default

Another absolutely massive quality of life fix here. As you'll also see below, one of my biggest v2 points was that non-deferred model binding was one of the biggest killers of most Livewire applications due to the sheer amount of API calls being made, every time you type a key, or select a value.


There are some other awesome updates, like injected assets, a new Livewire namespace, form objects, and more, but this isn't a blog post talking about the update, so I suggest reading all about it here

Tl;dr

  • Deferred model binding (now default in v3)
  • Don't pass large objects to Livewire components (still relevant)
  • Use events over polling
  • Use Alpine.js (still very relevant)

Model Binding

The Livewire documentation tells you to simply use wire:model to connect an input to Livewire:

<input type="email"
id="email-address"
wire:model="email"
name="email-address"
autocomplete="email"
className="block w-full input-control"
autocomplete="on"
required>

Sounds pretty easy, right?

But what does this look like as a user types into your field?

If you open up your Dev Tools (Cmd+Shift+C for users on a Mac) and go to the network tab and watch the requests as you type, you'll see that every letter you type, sends an API request.

This is a very easy performance sink, as your browser is now needing to make potentially thousands of API requests for each input field on your page.

For more experienced users of the web, they will encounter odd errors too, for example:

<input type="text"
id="first-name"
wire:model="firstName"
name="first-name"
autocomplete="first-name"
className="block w-full input-control"
autocomplete="on"
required>
 
<input type="email"
id="email-address"
wire:model="email"
name="email-address"
autocomplete="email"
className="block w-full input-control"
autocomplete="on"
required>

If I type my first name into the first field, and then immediately press tab and start typing into the email field, what do you think will happen?

Well, I've seen Livewire still not be finished sending a previous API request, which causes it to complete that request before the input in the email field has been entered, which then clears my input into the email field.

Of course, you do need to be quite quick to see this, but for myself, or any other young website visitor, this is a very likely issue for someone to run into.

So what's the fix?

Well, there are 2 fixes.

wire:model.lazy

By adding .lazy to your wire:model attributes, this will now cause the input field to update only once the user clicks away from it (i.e loses focus)

.lazy is typically good for text inputs, textareas, etc, but not things like select fields.

It's also something you'd typically use if you rely on that value being updated, i.e: suggesting categories based on a title. If you were to use the below method of .defer you would only get the updated title once another API request has been made, so you wouldn't be able to provide that live feel.

You can find the documentation on lazy updating here.

wire:model.defer - the winner

This is the way the inputs should be by default, and spoiler: it's the default for Livewire V3.

So in our case, if we have 2 input fields, and a submit button:

<form wire:submit.prevent="updateAction">
<input type="text"
id="first-name"
wire:model.defer="firstName"
name="first-name"
autocomplete="first-name"
className="block w-full input-control"
autocomplete="on"
required>
 
<input type="email"
id="email-address"
wire:model.defer="email"
name="email-address"
autocomplete="email"
className="block w-full input-control"
autocomplete="on"
required>
 
<button>Submit</button>
</form>

If you update both fields, as quick as you want, no API requests will be sent to the server, and no data will be lost.

As soon as the user presses the Submit button, Livewire will grab all dirty input fields, and group them together in a single request.

It will update the fields dirty deferred fields before anything else, before running the actual function, as it times when the values were changed, so it doesn't cause any validation issues, etc.

You can find the documentation on deferred updating here.

The Downside

It does have the downside where the field is not updated until another API request is made. This means you can't do live validation as the user is typing, or even lazy validation when the user clicks off the field.

You can't use the updatedInput event to do anything else (i.e populating another field) live for the user to see either (think a slug field)

So I recommend using .defer on all fields that don't need live updating capabilities, and .lazy on everything else which just debounces the input.

Large Models

The issue that caused me the biggest pain for Careerlane, was the location search. All locations were stored in the database, and I needed to get a filtered version of this, on each input change.

I initially did the worst thing possible: load all locations in the mount method, which caused the page to have over 16,000 models, which worked fine on my 2023 Macbook Pro, but not so much on my iPhone with not even a comparable amount of the processing power.

Input fields were lagged, clicking around was almost impossible, it honestly felt like my phone was crashing.

But hear me out, I did think this would be the best solution, as doing one single database call to grab all locations, and then using php/collections to filter that down, I did truly think that would net the biggest performance gain, but I was wrong.

I simply went back to just a raw API request, passing in the input on debounce of 250ms and then returning all of those locations matching the input.

One way you can test how many models you're loading in your component, is using Laravel Debugbar.

You can see on the bar in the bottom, how many models your request has loaded. This is a great way to tinker with the code to make sure the page loads fast and stays responsive too!

Use Events Over Polling

Instead of having your Livewire elements poll every few seconds, you should always utilise event litener to update the component once you can be certain a specific task/job was complete.

Otherwise, you

Use Alpine.js

This is one I will not understate - you simply cannot build production ready websites in pure livewire. Livewire is a simple wrapper around php + an API, and it simply replaces DOM elements as they need replacing.

You will run into so many performance issues, if you do something like this:

  1. Load all locations (+1 request)
  2. Have an input field linked to a model, and as this model is updated, filter those locations (+1 request), and +1 more each time the user types
  3. Loop through all locations and display them in the DOM (+1 request)
  4. Display these, and have a wire:click handler to select a location (+1 request)
  5. Update some field or value showing it as selected, and clear the filtered location (+2 requests)

So as you can see, a simple dropdown with options, using pure livewire will already give you 6 API requests at a bare minimum.

I dare you, to set something like this up, and throttle your connection using dev tools.

Whilst it may seem insanely fast locally, as soon you turn this on, you will see your website is unusable.

So instead, use Alpine.js:

Setup a data component to store your fields:

Alpine.data('searchData', () => ({
searchLocation: '',
searchedLocations: @entangle('filteredLocations'),
selectedLocation: @entangle('selectedLocation').defer
 
selectLocation(location) {
this.searchedLocations = [];
this.searchLocation = location.name;
},
});
<input type="text"
name="search-location"
id="search-location"
x-ref="searchLocation"
x-model="searchLocation"
wire:model.debounce="searchLocation"
@keydown.enter.prevent="selectLocation($refs.searchLocation.value)"
placeholder="e.g Brisbane"
class="theme-input !bg-white">

We @entangle the searchedLocations field, which is updated when our input is changed, on a debounce, and that's the only API requests to ever be done live.

You can filter, loop, and show everything using Alpine.js logic:

<ul x-show="searchedLocations && searchedLocations.length > 0"
class="absolute bottom-0 z-10 w-full py-1 mt-1 overflow-auto text-base translate-y-full bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
id="searchResults"
role="listbox">
 
<template x-for="location in searchedLocations">
<li class="relative py-2 pl-3 text-gray-900 cursor-default select-none pr-9 hover:bg-primary hover:text-white"
@click="selectLocation(location)"
tabindex="-1"><span class="block truncate"
x-text="location.name"></span></li>
</template>
</ul>

And since we are using .defer on the selectedLocation field in our Alpine.js store, this location is updated in the input field in real time, but only sent to your Livewire component with another request.

So instead of 6+ requests every time the user types, you're now only doing 1 on a debounce.

Alpine.js is not familiar to many PHP developers as its Javascript, but when you try to avoid something like React/Vue, but know you need to give some level of reactivity, then I don't think learning some Alpine.js is too much of a punishment.

Conclusion

The biggest learnings for me, was definitely leaning on Alpine.js more than Livewire, to handle simple things like open and closing modals, hiding/showing fields based on values, etc.

All of it can still be synced to Livewire, just make sure you use .defer to not slow down Alpine.

Keep the DOM size small, and don't use large collections of modals if you can avoid it.

Follow me on Twitter: @joelwmale