leek.io leek.io
contact
#programming #tutorial #ai

Livewire Islands Can Lose Your Clicks — Here's the Race and the Fix

CJ
Chris Jones · @leek
Nov 6, 2026 · 8 min read · 1,481 words
Livewire Islands Can Lose Your Clicks — Here's the Race and the Fix

The bug report was one sentence:

i click notes button, it works, i click another notes button, it works, on the third one it stops working and no longer loads the note modal

The page was a Filament kanban board. Each card has a Notes button that opens a slide-over. The board itself sat inside a Livewire 4 @island so that moving a card didn’t re-render the whole page.

Nothing was logged. No JavaScript error, no failed request, no exception in the server log. The first click worked, the second worked, and then one didn’t. Sometimes it was the third click, sometimes the fifth.

The cause turned out to be one sentence in the Livewire docs that’s easy to read past.

What islands do

An island is a region of a Livewire component that re-renders on its own:

@island(name: 'board')
    @foreach ($columns as $column)
        {{-- cards, each with a Notes button --}}
    @endforeach
@endisland

An action called from inside the island re-renders only the island, not the whole component. On a big board that’s the difference between a snappy page and a sluggish one, which is why it was there.

The catch is in the islands docs:

“Although island requests run in parallel, both islands and the root component can mutate the same component state.”

And when they conflict, “the last response to return will win.”

The race

Livewire normally queues a component’s requests one behind another. Each request carries the component’s snapshot, the server applies the call, and the next request starts from the new snapshot. You never get two requests built from the same starting state.

Islands get their own queue. In Livewire 4.4.8’s messageBus.js, the scope for a request is a symbol per component, unless every action in it came from an island, in which case it’s a symbol per island name:

function scopeSymbolFromAction(action) {
    let component = action.component
    let isIsland = !!action.metadata.island

    if (isIsland) {
        // one queue per island name
        return islandSymbols[action.metadata.island.name]
    }

    // one queue for the rest of the component
    return componentSymbols.get(component)
}

Here’s how that lost the click:

  1. You close one card’s Notes slide-over. The close runs on the component queue, because the modal lives outside the island.
  2. You immediately click the next card’s Notes button. The open runs on the island queue, because the button is inside the board.
  3. Two queues means nothing makes the open wait for the close. Both requests go out at once, with the same snapshot.
  4. The open answers first, and the slide-over starts to mount.
  5. The close answers second. Its snapshot was built from the state before the open, with no modal mounted. Last response wins, so Livewire applies it.

The slide-over never appears, and the component is now out of step with the page. That’s why it looked random: it only fails when the second click lands while the first request is still in flight.

If a component and its island both change the same state, island calls aren’t queued behind the component’s. Whichever response lands last wins.

How it was found

Server-side profiling would never find this. Every request succeeded. The bug lives entirely in the order the browser sends requests and applies their responses.

What found it was wrapping window.fetch in the browser and logging every Livewire request: which methods it called, whether it carried island metadata, when it went out and when it came back.

const originalFetch = window.fetch
let requestId = 0

window.fetch = async function (url, options) {
    const id = ++requestId
    const startedAt = performance.now()

    if (String(url).includes('livewire')) {
        const body = JSON.parse(options.body)
        const calls = body.components.flatMap((component) =>
            (component.calls ?? []).map((call) => ({
                method: call.method,
                island: call.metadata?.island?.name ?? null,
            })),
        )

        console.log(id, 'sent', calls)
    }

    const response = await originalFetch.apply(this, arguments)
    console.log(id, 'done', Math.round(performance.now() - startedAt), 'ms')

    return response
}

Then the clicks were scripted with a headless browser: open, close, open the next one fast. Two requests went out together, one with island: 'board' and one without, and the second response undid the first. After that, reading the installed livewire.js in vendor/ confirmed the separate island scope.

The fix: hold island calls behind the component’s

Livewire 4 exposes Livewire.interceptAction(), which runs before every action is sent and lets you defer it. The fix is an interceptor that keeps track of which calls of each component are in flight and on which queue. A new call waits if a call from a different queue of the same component is still in flight.

document.addEventListener('livewire:init', () => {
    const states = new WeakMap()

    const stateOf = (component) => {
        if (!states.has(component)) {
            states.set(component, { inFlight: new Map(), held: [] })
        }

        return states.get(component)
    }

    const queueOf = (action) => action.metadata?.island?.name ?? ''

    window.Livewire.interceptAction(({ action, onFinish }) => {
        const state = stateOf(action.component)

        const otherQueueBusy = [...state.inFlight].some(
            ([other, queue]) => other !== action && queue !== queueOf(action),
        )

        onFinish(() => {
            state.inFlight.delete(action)
            // release the oldest held call, if nothing else is in flight
        })

        if (state.held.length > 0 || otherQueueBusy) {
            action.defer()
            state.held.push(action)

            return
        }

        state.inFlight.set(action, queueOf(action))
    })
})

That’s the shape. The production version handles more, and every extra case came from review.

Release held calls one at a time, oldest first. The first version released every held call at once when the blocker finished. Livewire then bundled them into one request whose combined scope (board|board) didn’t match either queue, which reopened the race. One at a time, only once nothing of that component is in flight, fixes it.

Recheck calls Livewire defers on its own. Livewire holds back some actions itself and sends them later. By the time it does, another queue may have started, so the interceptor wraps the sender and checks again.

Drop cancelled calls instead of sending them after the fact.

Treat polls the way Livewire’s own queue does. A wire:poll that would have to wait is dropped, and a user’s click cancels an in-flight poll rather than waiting behind it. Otherwise a background poll on a chat list can hold up a click for a whole round trip.

Test it without a browser

The interceptor is plain JavaScript, so it gets plain node:test tests. A small loadIslandQueue() helper stubs Livewire.interceptAction, imports the script, and returns fake actions that record what was sent. Then each test is a sequence of clicks and finishes:

import assert from 'node:assert/strict'
import test from 'node:test'

test('a call from inside an island waits for the page call in flight', async () => {
    const queue = await loadIslandQueue()

    const close = queue.call('unmountAction')
    queue.call('mountAction:notes-2', { island: 'board' })

    assert.deepEqual(queue.sent, ['unmountAction'])

    queue.finish(close)

    assert.deepEqual(queue.sent, ['unmountAction', 'mountAction:notes-2'])
})

test('held calls are sent one at a time, oldest first', async () => {
    const queue = await loadIslandQueue()

    const close = queue.call('unmountAction')
    queue.call('mountAction:notes-2', { island: 'board' })
    queue.call('mountAction:notes-3', { island: 'board' })

    queue.finish(close)

    assert.deepEqual(queue.sent, ['unmountAction', 'mountAction:notes-2'])
})

These run in CI with node --test, with no browser needed.

Four of those tests failed against the first version of the fix, which is how I know they test something real.

Should you just avoid islands?

No, but know what you’re opting into. An island is worth it when it has its own state, like a feed, a chart, or a counter. The trouble starts when an action inside the island and an action outside it both change the same component state, and the user can trigger both within one round trip. Modals are the classic case: the trigger lives in the island, the modal lives on the component.

If that’s you, there are three options:

  • Move the triggering button out of the island, so its call joins the component’s queue.
  • Keep shared state off the component and give the island its own.
  • Or keep the layout and add an interceptor like the one above.

Option three uses Livewire’s public interceptAction hook, but the production version also wraps an internal _fire method to catch actions Livewire defers itself. Internals change, so pin your Livewire version and keep the tests that fail when the race comes back.

The general lesson

Everything about this bug looked healthy from the server. Every request returned 200, the component tests passed, and the logs were clean. The failure was purely about ordering: two correct responses, applied in the wrong order.

When a UI bug is intermittent and leaves no errors, stop looking at what each request did and start looking at when each one went out and came back.

Intermittent and silent usually means a race. Log the order of requests and responses in the browser, not the requests themselves.

CJ
Reply by email
I read everything.
$ reply