Debounced Queued Event Listeners in Laravel 13.26
Laravel #Laravel #Queues #Events #Laravel 13 #Performance

Debounced Queued Event Listeners in Laravel 13.26

4 min read Mohamed Said Mohamed Said

The Problem: Redundant Queue Work

A product import touches the same record forty times in a minute. ProductUpdated fires forty times. The listener that rebuilds the search index runs forty times, each run indexing state the next one immediately overwrites. The queue does exactly what it was told, but 97 percent of the work is waste.

What you actually want is for a burst of identical events to collapse into one listener execution at the end of the burst, carrying the latest state.

Laravel 13.6 introduced 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 same behavior without restructuring listeners into manually dispatched jobs.

Debouncing a Queued Listener

Add the #[DebounceFor] attribute to any listener that implements ShouldQueue and specify 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 event for product 42 within 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 share one window — ideal for global listeners like "rebuild the sitemap". The ID can also be a plain $debounceId property when it does not depend on the event payload.

How the Debounce Mechanism Works

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. When an older queued copy finally executes, it checks whether it still owns the token — if not, it discards itself silently.

One important caveat: a single event on an otherwise idle resource still waits out the full debounce window before executing. There is no "fire immediately on first event" shortcut.

Preventing Starvation with maxWait

Pure debouncing has a failure mode: a continuous stream of events that never pauses long enough for the window to expire defers the listener indefinitely.

maxWait solves this. With #[DebounceFor(30, maxWait: 120)], once dispatches have been pushing the window 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 — rather than either forty runs or zero.

Rules and Constraints

Three things to know before rolling this out:

  • No ShouldBeUnique together. Combining the two attributes throws a LogicException at dispatch time. They hold opposite semantics — first-wins vs. last-wins — and the framework refuses to pick silently.
  • Debouncing is scoped to the listener, not the event. Other listeners on ProductUpdated still run for every event. Only the attributed listener collapses.
  • Re-read state in the handler. 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 exactly this reason. Treat the event as a pointer to a resource, not as a complete 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.

Key Takeaways

  • #[DebounceFor(seconds, maxWait: seconds)] on a ShouldQueue listener collapses event bursts into one execution.
  • debounceId() scopes the debounce window per resource; omit it for global listeners.
  • maxWait prevents indefinite deferral under sustained event streams.
  • Cannot be combined with ShouldBeUnique.
  • Always call ->fresh() or re-query state inside the handler; the surviving event object may be stale.

Source: Debounced Queued Event Listeners in Laravel — Laravel News

Found this useful?

Frequently Asked Questions

3 questions
Q01 What does the `#[DebounceFor]` attribute do on a Laravel queued event listener?
It collapses a burst of identical events into a single listener execution. Each dispatch queues the listener with a delay equal to the debounce window and records an owner token in the cache. If a newer dispatch arrives before the delay expires, it overwrites the token and the older queued copy discards itself when it runs, leaving only the last dispatch to execute.
Q02 How does `maxWait` prevent a debounced listener from never running under a continuous event stream?
Without `maxWait`, a stream of events that never pauses for the full debounce window would defer the listener indefinitely. Setting `maxWait` caps the total deferral time: once dispatches have been pushing the window for that many seconds, the next dispatch executes immediately instead of extending the delay again.
Q03 Can `#[DebounceFor]` be combined with `ShouldBeUnique` on the same listener?
No. Combining them throws a `LogicException` at dispatch time. `ShouldBeUnique` is first-wins (only the first job in the window runs) while `#[DebounceFor]` is last-wins (only the most recent dispatch runs). The framework treats the combination as a logic error rather than silently picking one behavior.

Continue reading

More Articles

View all