Posted

So you want to deploy Livewire behind a load balancer

Livewire’s great when you want to add a bit of interactivity to pages, but you don’t want to build a single page application (plus an API to power it), or you’re more comfortable with Laravel’s Blade templates than front end libraries like React or Vue. It has its quirks though, one of which hit us pretty hard recently.

Setting the scene

A client of ours asked us to build a booking system for them. It has a public-facing component, allowing the general public to make bookings online, but it also has an admin-restricted component, part of which is a form that allows admins to create bookings on the behalf of customers. This form is written in Livewire and is quite complicated to accommodate all the business logic the client needs.

This form was initially built as a single Livewire component with some custom traits (as recommended in their docs) to group separate bits of logic together. Despite this, I found it very difficult to understand how this component actually worked, since tracing through how a single interaction worked involved jumping between the component class, its traits and the Blade view it rendered (and its included subviews).

Divide and conquer

To simplify this, I decided to refactor this into a few smaller components that were included by one parent component. The form already had four clear sections, so it was quite simple to split this up into a component for each section:

  • A component for choosing what to book
  • A component for selecting an existing customer, or entering details for a new customer
  • A component for any extra notes to add to the booking
  • A component outlining the payment schedule and providing a couple of options to tweak that

The parent component is then responsible for actually creating the booking, using data received from its child components via Livewire events.
There were few hiccups along the way (I didn’t write the form originally so this was my first time digging into Livewire), but overall it was a fairly smooth experience and the form was both performing well and the separate components with clear event-based interfaces made it much easier to understand everything.

But it works on my machine!

With the development ‘done’, it was time to push up my branch and get my changes running in our QA environment. Naturally, given that I write perfect code 100% of the time, I pushed the branch, waited for the build to succeed and tests to pass, assigned the ticket to QA and walked away.

To my surprise, my code was not perfect…

via GIPHY

QA couldn’t complete the form since sometimes after typing in a field, all the inputs on the form would be cleared. Worst of all, no matter what I tried, I couldn’t replicate the issue locally and I could only sometimes replicate it in the QA environment. I tried cutting parts out of the component to find the minimum amount of code that reproduced the issue. A colleague tried commenting out events the component fired. And we both poured over the docs and the source code to find what was causing this. Nothing worked.

It seemed like Livewire was deciding to re-render the parent component, throwing out all the state of the child components in the process, but only sometimes. What would cause Livewire to do that?

On a whim, we took a look at some of the responses Livewire was sending. Livewire has one endpoint that it uses to request data and HTML snippets whenever the user interacts with something: /livewire/component/{component name}.

When the bug didn’t happen, responses from /livewire/component/create-booking.form (the parent component) looked like this:

<div wire:id="mxaVLnqcsr7AJgvqmvrP">
  <h1 class="text-3xl font-bold text-center mb-9">Create a booking</h1>
  <div class="mb-9">
    <div wire:id="ulwFJBODrXiKURiEWA11"></div>  </div>
  <hr />
  <div class="mb-9">
    <div wire:id="idTPTjiJkPH2zsv3p3E5"></div>  </div>
  <hr />
  <div class="mb-9">
    <div wire:id="4PU8a7oG50tzJio6dl5B"></div>  </div>
  <hr />
  <div class="mb-9">
    <div wire:id="BPqkEI4G0FIkxJAuP7ft"></div>  </div>
  <hr />
  <div class="mb-4">
    <button
      class="mt-1 block w-full bg-charcoal-900 hover:bg-gray-400 text-white font-bold px-3 py-2 sm:py-4 sm:pl-5 text-base leading-6 border-gray-200 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5 text-center appearance-none"
      wire:click="createBooking"
      wire:loading.attr="disabled"
    >
      Create Booking
    </button>
  </div>
</div>

But when the bug did happen, the responses looked like this:

<div wire:id="mxaVLnqcsr7AJgvqmvrP">
  <h1 class="text-3xl font-bold text-center mb-9">Create a booking</h1>
  <div class="mb-9">
    <div wire:id="argny1UkAMeOkWfvzvaD" wire:initial-data="{ /* Lots of data */ }">
      <h3 class="text-2xl font-bold mb-3">Booking details</h3>
      <!-- The entire child component's rendered HTML -->
    </div>
  </div>
  <hr />
  <div class="mb-9">
    <div wire:id="8n5oH0kIdVEV7PdWeMO2" wire:initial-data="{ /* Lots of data */ }">
      <h3 class="text-2xl font-bold mb-3">Customer details</h3>
      <!-- The entire child component's rendered HTML -->
    </div>
  </div>
  <hr />
  <div class="mb-9">
    <div wire:id="9AmgNDdsVXgkWTRldnwy" wire:initial-data="{ /* Lots of data */ }">
      <h3 class="text-2xl font-bold mb-3">Extra details</h3>
      <!-- The entire child component's rendered HTML -->
    </div>
  </div>
  <hr />
  <div class="mb-9">
    <div wire:id="0kBf5twBpYr6xGbIcLi2" wire:initial-data="{ /* Lots of data */ }">
      <h2 class="text-2xl font-bold mb-3">Payment</h2>
      <!-- The entire child component's rendered HTML -->
    </div>
  </div>
  <hr />
  <div class="mb-4">
    <button
      class="mt-1 block w-full bg-charcoal-900 hover:bg-gray-400 text-white font-bold px-3 py-2 sm:py-4 sm:pl-5 text-base leading-6 border-gray-200 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5 text-center appearance-none"
      wire:click="createBooking"
      wire:loading.attr="disabled"
    >
      Create Booking
    </button>
  </div>
</div>

Ok, so that confirms that Livewire’s rendering the parent component in full, but what’s the deal with those empty <div>s when Livewire is working? You may have noticed that the wire:id on the enclosing <div> is the same in both cases, but the wire:ids of the child components are different. That’s the clue that’ll lead us to the answer.

We delved too deep

There was a lot of time spent reading Livewire’s source at this point. To cut to the chase, the problem lies in the code behind the @livewire() Blade directive. If the $_instance variable is set in the view (which is added in Livewire\Component::output() and represents the direct parent component), Livewire checks if the current component has already been rendered as a child of $_instance using a “cached key”. If it’s already been rendered, Livewire outputs an empty element as we saw earlier:

<div wire:id="ulwFJBODrXiKURiEWA11"></div>

otherwise, it renders the child from scratch and tracks that it’s been rendered on the parent component.

<?php 
public static function livewire($expression)
{
    $lastArg = str(last(explode(',', $expression)))->trim();
    if ($lastArg->startsWith('key(') && $lastArg->endsWith(')')) {
        $cachedKey = $lastArg->replaceFirst('key(', '')->replaceLast(')', '');
        $args = explode(',', $expression);
        array_pop($args);
        $expression = implode(',', $args);
    } else {
        $cachedKey = "'".str()->random(7)."'";
    }
    return <<<EOT
<?php
if (! isset(\$_instance)) {
    \$html = \Livewire\Livewire::mount({$expression})->html();
} elseif (\$_instance->childHasBeenRendered($cachedKey)) {
    \$componentId = \$_instance->getRenderedChildComponentId($cachedKey);
    \$componentTag = \$_instance->getRenderedChildComponentTagName($cachedKey);
    \$html = \Livewire\Livewire::dummyMount(\$componentId, \$componentTag);
    \$_instance->preserveRenderedChild($cachedKey);
} else {
    \$response = \Livewire\Livewire::mount({$expression});
    \$html = \$response->html();
    \$_instance->logRenderedChild($cachedKey, \$response->id(), \Livewire\Livewire::getRootElementTagName(\$html));
}
echo \$html;
?>
EOT;
} 

The $_instance->childHasBeenRendered() just checks for the presence of the cached key in an array on the parent component.

public function childHasBeenRendered($id)
{
    return in_array($id, array_keys($this->previouslyRenderedChildren), true);
}

That array gets filled in an event handler using the children part of the serverMemo, which is a chunk of JSON that gets passed back and forth between the client-side and server-side to keep track of a component’s state.
serverMemos look like this:

{
    "fingerprint": {
        "id": "mxaVLnqcsr7AJgvqmvrP",
        /* Other bits that identify the component */
    },
    "serverMemo": {
        "children": {
            "qDNg9Ah": {
                "id": "ulwFJBODrXiKURiEWA11",
                "tag": "div"
            },
            "Bb3kYox": {
                "id": "idTPTjiJkPH2zsv3p3E5",
                "tag": "div"
            },
            "s9Fwdqz": {
                "id": "4PU8a7oG50tzJio6dl5B",
                "tag": "div"
            },
            "dR7w9UR": {
                "id": "BPqkEI4G0FIkxJAuP7ft",
                "tag": "div"
            }
        },
        "errors": [],
        "htmlHash": "a9f13086",
        "data": { /* The current state of the component */ },
        "dataMeta": [],
        "checksum": "5d9b66aedb136bffbc6d42e6eaa1f5cacd2f35bb51cb483e14dc119a39d2eb4c"
    },
    "updates": [/* Details of things like events */]
}

The keys of the children object are the cached keys for each child component. At this point, you might be wondering how any of this ever works. Livewire’s tracking the children of a parent component using a random key, but the code that checks if a child component has already been rendered does that by generating another random key? What? Indeed, when this bug occurs, the cached keys of the child components change, along with the child component’s IDs, which starts to explain why Livewire is re-rendering the child components.

One fix for this is to just avoid randomly generated cached keys entirely. Livewire allows you to set a key on a component to tell it how to keep track of it (like this: livewire('component name', key('key here'))). This is supposed to be used when rendering the same component in a loop, but it fixes this bug too: the cached keys of child components never change, so Livewire can always keep track of child components and it never re-renders child components unnecessarily.

But… why?

Ok great, we’ve fixed the bug. But we still didn’t understand why at this point. Sometimes Livewire can keep track of child components using the randomly generated cached keys. In fact, let’s not forget that this bug doesn’t happen locally, it’s something about our QA environment that causes this.
I was completely stumped on this until another colleague just casually reminded me about Laravel’s view cache. As it turns out, Laravel caches the code that’s returned by a directive, so it only runs a single directive call once. Let’s take another look at the @livewire() directive with that in mind.

public static function livewire($expression)
{
    // *snip*
    $cachedKey = "'".str()->random(7)."'";
    return <<<EOT
<?php
if (! isset(\$_instance)) {
    \$html = \Livewire\Livewire::mount({$expression})->html();
} elseif (\$_instance->childHasBeenRendered($cachedKey)) {
    \$componentId = \$_instance->getRenderedChildComponentId($cachedKey);
    \$componentTag = \$_instance->getRenderedChildComponentTagName($cachedKey);
    \$html = \Livewire\Livewire::dummyMount(\$componentId, \$componentTag);
    \$_instance->preserveRenderedChild($cachedKey);
} else {
    \$response = \Livewire\Livewire::mount({$expression});
    \$html = \$response->html();
    \$_instance->logRenderedChild($cachedKey, \$response->id(), \Livewire\Livewire::getRootElementTagName(\$html));
}
echo \$html;
?>
EOT;
}

Since the str()->random() call happens outside of the returned code, that only gets called once and then the value it returned gets stored in the view cache along with the rest of the code. That’s how Livewire works when using randomly generated cached keys! They’re randomly generated but only once! mind blown

via GIPHY

This explains why this issue occurs in our QA environment. Our QA environment is a Kubernetes cluster that is configured to use between 2 and 8 pods. Now, when you run multiple instances of an app like this, a lot of resources are shared: you’ll probably have a single database, maybe Redis for sessions or queued jobs and maybe S3 for storing and retrieving files. Do you know what isn’t shared between pods? Laravel’s view cache! Cached views get stored as files in the local filesystem, meaning two separate instances of your app could have slightly different cached versions of the same Blade file.

So, if you get lucky, when Livewire makes a request it’ll get routed to the same pod as the previous request, in which case the cached keys will match up and everything will work as expected. But if you get unlucky, the request will go to a different pod, the cache keys won’t match up and Livewire will think this is a new child component and re-render it from scratch. That explains why this only sometimes happens!

It also explains why this doesn’t happen locally. You’re (probably) only running a single instance of your app locally, so there’s only one version of the cached Blade files, so the cached keys will always match up. One way that you can replicate this locally though is by clearing the view cache part way through using a Livewire component. You’ll find that as soon as you do that, Livewire will re-render all child components.

Great, but now what?

We’ve found the root cause of this bug, cool. How do you work around it?

Well as mentioned before, you can specify this “cached key” on a child component using livewire('component name', key('key here')). That might be a workable solution for you, but that could be a big job if you’ve got a lot of child components to update. One thing to bear in mind though is that these keys only need to be unique to the parent component they’re rendered in. They don’t need to be globally unique.

Another option would be sharing Laravel’s view cache between all the instances of your app. That’s not a great solution though. The reason Laravel writes cached Blade files to the local filesystem is to keep the view rendering fast. Laravel doesn’t provide an option to use a different filesystem driver for the view cache since fetching those files from something like S3 could add a noticeable delay to view rendering time, and therefore response time. If you really want to do this though, a shared filesystem like AWS’ EFS is an option.
The ideal solution would be if Livewire generated a cached key in a deterministic way, so that no matter which instance of your app handles a request, it always generates the same key. As it just so happens, a PR was just merged that does that very thing. Hopefully, that’ll make issues like this a thing of the past.

To get this far, you must really like solving problems; so do we. Lucky for you, we’re hiring.