Laravel Livewire Performance Tips & Tricks

Updated:

I've recently been working on my first side project, that is almost complete: Careerlane. However, when I pushed this code to the "soon-to-be" production server, I noticed so many performance issues when the server was no longer my local development environment.

I've spent the last week, combing through the application to fix these issues, and now I'm going to share them, so future me doesn't fall into these traps, and doesn't have to spend a week after development optimising the website, so it feels like a website built in 2023.

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.

Tl;dr

  • Deferred model binding
  • Don't pass large objects to Livewire components
  • Use Alpine.js

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 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.

Livewire V3 will include many performance improvements, so once that's released, I'll update this blog post with the latest!

Follow me on Twitter: @joelwmale