News

Debounced Queued Event Listeners in Laravel

Published
Debounced Queued Event Listeners in Laravel image

A product import touches the same record forty times in a minute, ProductUpdated fires forty times, and the listener that rebuilds the search index runs forty times, each run indexing state the next one overwrites. The queue does exactly what it was told and the work is still 97 percent waste. What you want is for a burst of identical events to collapse into one listener run at the end of the burst, carrying the latest state.

Laravel 13.6 added that collapse for queued jobs, as debounceable queued jobs. Laravel 13.26 extends the same #[DebounceFor] attribute to queued event listeners, contributed by @stevebauman in #61169, so event-driven code gets the behavior without restructuring listeners into manually dispatched jobs.

Community packages have filled the gap, Laravel Debounce among them, and the framework's own answer started with jobs.

Debouncing a Listener

Add the attribute to a queued listener and give it a debounce window in seconds:

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\DebounceFor;
 
#[DebounceFor(30, maxWait: 120)]
class UpdateProductSearchIndex implements ShouldQueue
{
public function debounceId(ProductUpdated $event): string
{
return (string) $event->product->getKey();
}
 
public function handle(ProductUpdated $event): void
{
ProductIndexer::index($event->product->fresh());
}
}

Every ProductUpdated for product 42 inside a 30-second window now results in one handle() call, made for the last event of the burst. Events for product 43 debounce independently, because debounceId() keys the window per product. Without a debounceId, all dispatches of the listener share one window, which is what you want for listeners like "rebuild the sitemap" that take no per-resource input. The ID can also be a plain $debounceId property when it does not depend on the event.

With debouncing, the last dispatch wins. Each dispatch queues the listener with a delay equal to the debounce window and records an owner token in the cache, keyed by listener class and debounce ID. A newer dispatch overwrites the token, and when an older queued copy finally executes, it sees it no longer owns the token and discards itself. One caveat follows from the delay-based design: a quiet resource still waits out the full window, so a single event on an idle product runs 30 seconds later, not immediately.

maxWait and the Starvation Problem

Pure debouncing has a failure mode: a stream of events that never pauses for 30 seconds defers the listener forever. That is what maxWait is for. With #[DebounceFor(30, maxWait: 120)], once dispatches have been pushing the window along for 120 seconds, the next dispatch executes without delay instead of extending the deferral again. A busy import still gets its writes collapsed, roughly one index run per two minutes, instead of either forty runs or zero.

Two dials interact with the delay. An explicit delay set on the listener takes precedence over the debounce-derived one, and a listener can override which cache store manages the ownership tokens by defining debounceVia(), useful when your default cache is not shared by every server dispatching events.

The Rules

Three constraints to know before rolling this out:

  • No ShouldBeUnique. The two features hold opposite locks, first-wins versus last-wins, and combining them now throws a LogicException at dispatch rather than picking a winner silently
  • Scope is the listener, not the event. Other listeners on ProductUpdated run for every event; only the attributed listener collapses. Debouncing is a property of the work, not of the event stream
  • The handler sees the triggering event, so re-read state. The event object that survives the debounce is the last one dispatched, but by execution time even it can be stale. The example above calls $event->product->fresh() for that reason; a debounced listener should treat the event as a pointer to a resource, not as a payload

That last habit is what makes debouncing safe: if the listener re-derives its output from the database, collapsing forty runs into one changes the cost, not the result.

Further Reading

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Filed in

Sponsored

laravelcloud logo
Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Cloud

The latest

View all →
A simple form builder that stays out of your way image

A simple form builder that stays out of your way

Read article
Laravel AI: Trace Agent Runs With Lifecycle Events image

Laravel AI: Trace Agent Runs With Lifecycle Events

Read article
Laravel AI: Get Raw HTTP Responses and Rate Limits image

Laravel AI: Get Raw HTTP Responses and Rate Limits

Read article
Agent Run Observability in Laravel AI SDK 0.11 image

Agent Run Observability in Laravel AI SDK 0.11

Read article
Statamic Mailables Viewer Previews Laravel Emails in the Control Panel image

Statamic Mailables Viewer Previews Laravel Emails in the Control Panel

Read article
Laravel Tackle: Run an AI Coding Agent in Your Laravel App image

Laravel Tackle: Run an AI Coding Agent in Your Laravel App

Read article