livewire/livewire: Throttled inputs no longer get secretly debounced by .live
Contributed by ghabriel25
Part of the livewire v4.4.7 release
Pull request #10492 fixes a subtle timing bug in Livewire's wire-model directive: inputs using an explicit .throttle modifier were also getting an invisible default 150ms debounce stacked on top by the .live modifier. If you've ever written wire:model.live.throttle.30ms and wondered why your input still felt sluggish despite the tiny throttle window, this is why. The fix lands on the 4.x branch, and because it changes when network requests fire for realtime inputs, it deserves a moment of attention as you upgrade from v3.8.10.
The bug: throttled inputs were silently debounced
On recent v3 releases, an input like this:
<input type="text" wire:model.live.throttle.30ms="search" />
wasn't actually throttle-only. Behind the scenes, Livewire wrapped the update function as:
debouncedUpdate = throttle(debounce(update, 150), 30)
The intent of .throttle.30ms is "send at most one request every 30ms." Instead, every keystroke's update first sat behind a 150ms debounce (the default .live duration), and only then passed through the 30ms throttle. The debounce dominates: the throttle window is effectively irrelevant, updates land roughly 150ms after you stop typing, and back-to-back requests collapse into one.
This is the kind of bug that's nearly invisible in manual testing — everything "works," just later than it should — which is why it survived since it was introduced in #9597 and carried through #9832. Only a browser test counting actual network requests exposes it.
The root cause
The debounce/throttle selection in js/directives/wire-model.js looked like this:
if ((shouldSendNetwork && ! hasNetworkTriggers && isRealtimeInput(el)) || isDebounced) {
debouncedUpdate = debounce(debouncedUpdate, parseModifierDuration(networkModifiers, 'debounce') || 150)
}
if (isThrottled) {
debouncedUpdate = throttle(debouncedUpdate, parseModifierDuration(networkModifiers, 'throttle') || 150)
}
The first branch exists to give plain .live inputs a sensible default debounce. The problem is the logical ||: an input with .live.throttle.30ms still satisfies shouldSendNetwork && ! hasNetworkTriggers && isRealtimeInput(el), because nothing in that expression checks whether the element already has an explicit throttle or debounce. So the default 150ms debounce was applied even though the developer asked for throttle-only behavior.
The fix is a one-line guard:
if ((shouldSendNetwork && ! hasNetworkTriggers && isRealtimeInput(el) && ! isDebounced && ! isThrottled) || isDebounced) {
debouncedUpdate = debounce(debouncedUpdate, parseModifierDuration(networkModifiers, 'debounce') || 150)
}
Now the default .live debounce only applies when the element has neither an explicit .debounce nor an explicit .throttle. .throttle.30ms produces exactly throttle(update, 30) — nothing more.
What changes when you upgrade
Behavior per combination:
wire:model.live— unchanged. Realtime inputs still get the default 150ms debounce.wire:model.live.debounce.300ms— unchanged. Explicit debounces always won.wire:model.live.throttle.30ms— changed. No more implicit 150ms debounce; requests now fire on the throttle cadence you specified.
The one real upgrade impact: throttle-only inputs will hit your server more often than before. That's the behavior you asked for, but if you have many high-frequency inputs — or were unknowingly relying on the accidental debounce as a request-volume shield — budget for the difference. If you want the old combined timing, make the debounce explicit:
<input wire:model.live.throttle.30ms.debounce.150ms="search" />
A concrete before/after
Take the scenario from the new browser test — a search box with a live-updating label:
<input type="text" wire:model.live.throttle.30ms="foo" dusk="input" />
<span wire:text="foo" dusk="text"></span>
Type ab slowly, one keystroke every ~50ms:
- Before the fix: both updates queue behind a single 150ms debounce. The
wire:textspan doesn't showauntil the debounce fires, and the two keystrokes collapse into one network request. - After the fix: the first keystroke fires a request immediately (the throttle's leading edge), the second follows 30ms later. The span tracks your typing, and the test counts 2 requests instead of 1.
That difference — 2 requests vs 1, and instant label updates vs a 150ms lag — is the entire bug in miniature.
Takeaways
.liveno longer stacks its default 150ms debounce onto inputs that already have.throttleor.debounce.- Plain
wire:model.livebehavior is unchanged. - Throttle-only inputs will send network requests more frequently after upgrading — by design.
- To keep the old debounce + throttle timing, add an explicit
.debouncemodifier. - Fixed with a one-line guard in
wire-model.js, plus a regression browser test.