Event bursts: debounce, uniqueness or rate limiting, which should you use?

Arnaud Pointet · · 9 min read

Lire cet article en français

This article starts from an exercise you can do in your browser: Handling only the last event of a burst with #[DebounceFor] →

A product gets edited three times in ten seconds, because someone fixes a typo, then the price, then the photo. An application that reindexes the product in the search engine on every change does the same expensive work three times. And if it goes about it the wrong way, it also indexes the wrong version.

That's the topic of the Practice exercise on #[DebounceFor], an attribute that shipped with Laravel 13.26. The exercise starts from a listener guarded by ShouldBeUnique, which lets a subtle bug slip through, and fixes it with the new attribute. This article picks up from there, looks at how the mechanism works, then compares the different ways to absorb a burst.

The starting bug: unique, but stale

The listener in the exercise looks like something we write all the time:

class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
    public int $uniqueFor = 30;

    public function __construct(private SearchIndex $index) {}

    public function uniqueId(ProductUpdated $event): string
    {
        return (string) $event->productId;
    }

    public function handle(ProductUpdated $event): void
    {
        $this->index->upsert($event->productId, $event->name);
    }
}

Three edits back to back, a single indexing run: the count test passes. But ShouldBeUnique takes a lock on the first dispatch and discards the ones that follow for as long as that lock exists, that is, until processing finishes or at most uniqueFor seconds. So it's the first event that gets processed, with the first name. The index shows "Keyboard" while the product has been called "Mechanical keyboard" for twenty seconds, and nothing is ever going to fix it.

ShouldBeUnique answers the question "is this work already scheduled?" Here, the real question is "what is the latest state to process?" Those are two different needs.

The fix: wait for the burst to end

#[DebounceFor(30)]
class UpdateSearchIndex implements ShouldQueue
{
    public function __construct(private SearchIndex $index) {}

    public function debounceId(ProductUpdated $event): string
    {
        return (string) $event->productId;
    }

    public function handle(ProductUpdated $event): void
    {
        $this->index->upsert($event->productId, $event->name);
    }
}

The term comes from electronics, where a physical button "bounces" and sends several signals for a single press. You wait for the signal to settle before reading it. Front-end developers know it well: it's the search box that only queries the server once you've stopped typing.

Laravel in fact refuses to let a listener be both debounced and ShouldBeUnique: it throws a LogicException. The two strategies are incompatible by design.

How it works

The source code is short and worth reading, because it sheds light on the mechanism's limits.

  1. On every dispatch, Laravel writes a random token to the cache, under a key derived from the listener and the debounceId: the current "owner". The job is queued with a 30-second delay and carries that token with it. Watch out: if the listener already sets its own delay (withDelay() or #[Delay]), that delay takes precedence over the DebounceFor one, without any warning.
  2. A new dispatch for the same product writes a new token to the cache, replacing the old one.
  3. When a job comes due, the worker compares its token with the one in the cache. If they differ, a more recent dispatch exists: the job is deleted without running, and Laravel fires a JobDebounced event. Otherwise, the job runs.

The result: only the job from the last dispatch goes through, 30 seconds after it. This mechanism has several consequences.

Jobs are created, then thrown away

Debouncing doesn't save you anything on the queue: three edits still create three jobs, and the worker still dequeues three. What you save is the work: two of the three jobs stop after a single cache read. For indexing that costs a network call to Elasticsearch or Meilisearch, that's what matters most. But for an import of 50,000 products, that's still 50,000 jobs in the queue.

The cache becomes a coordination point

The whole decision rests on the cache. Across several servers, it has to be shared (Redis, Memcached, database): a file or array cache local to each machine would break the mechanism entirely. The listener can point to a cache store other than the default one with a debounceVia() method.

And if the key disappears from the cache (eviction under memory pressure, a Redis restart without persistence), Laravel chooses to run the job rather than lose it. That's the right call, but it means duplicate processing is still possible. Debouncing reduces duplicates; it doesn't rule them out.

The most common case isn't eviction, by the way: the token lives for ten times the window, and at least five minutes. If the queue falls further behind than that, the key expires before the jobs are dequeued, and all the jobs in the burst run. So a clogged queue, precisely on the day there are lots of edits, loses its debounce.

The last event gets processed, with the delay on top

Work starts at the earliest 30 seconds after the last edit. For a search index, that's acceptable. For a "your order has shipped" notification, much less so. Picking the window means trading off the number of runs you avoid against data freshness.

The continuous-stream trap: maxWait

Picture a product whose stock is updated every ten seconds by a warehouse. With a 30-second window, each update pushes back the previous one: the burst never ends, and the product is never reindexed.

That's what maxWait is for:

#[DebounceFor(30, maxWait: 120)]

Laravel records the time of the burst's first dispatch. If a dispatch arrives when 120 seconds have already passed, it's queued with no delay, and that counter resets to zero. The delay is therefore capped at roughly two minutes, with one caveat: maxWait guarantees a dispatch with no delay, not an execution. If the queue itself is lagging and a new dispatch comes in before the worker has picked up that job, its token gets replaced, and it's thrown away like the others. (Since Laravel 13.27, the maxWait window also resets when a debounced job runs.)

As soon as an event can arrive continuously, I add a maxWait. Without it, a debounce can indefinitely delay processing of a very active object, and that's often the most important object.

Process the state, not the event

There's one weakness left in the exercise's listener, which the exercise doesn't ask you to fix: it indexes the name carried by the event. As long as the last event processed really is the most recent one, all is well. But between a retry after an error, a duplicate job after a cache eviction and two workers draining the queue in a slightly different order, "the last event" is a shakier notion than it looks.

In production, I'd rather have the listener reload the current state:

public function handle(ProductUpdated $event): void
{
    $product = Product::find($event->productId);

    if ($product === null) {
        $this->index->remove($event->productId);

        return;
    }

    $this->index->upsert($product->id, $product->name);
}

The event no longer says "here's the new name", but "this product changed, go take a look". Processing becomes idempotent: running it twice, or out of order, produces the same index. Debouncing is then only there to save work, no longer to guarantee data correctness. That's a much more robust division of responsibilities.

A corollary: an event dispatched inside a database transaction must go out after the commit (ShouldDispatchAfterCommit on the event, or after_commit on the queue connection). Otherwise, the worker may reread the database before the change is visible. With a 30-second delay, that's unlikely, but a slow transaction is still possible, and hitting maxWait sends the job out with no delay. When dispatch is deferred until after the commit, Laravel also releases the debounce lock if the transaction is rolled back.

Debounce, uniqueness, rate limiting: the table

There are several related tools for absorbing a burst, and they don't answer the same question:

Tool What it keeps When to use it
#[DebounceFor] the last dispatch, after a quiet period syncing state that changes in bursts: search index, cache, export to a third-party service
ShouldBeUnique the first dispatch; later ones are ignored until it's processed work that doesn't depend on the event's payload: "recalculate the statistics", "regenerate the sitemap"
ShouldBeUniqueUntilProcessing the first dispatch, but a new dispatch is let through as soon as processing starts same as above, when a change made during processing must also be taken into account
WithoutOverlapping middleware every dispatch, but never two at once for the same key protecting a resource that can't handle concurrency
RateLimited middleware every dispatch, spread out over time staying within an external API's quota

A note on the third row: if the listener reloads the state from the database, as in the previous section, ShouldBeUniqueUntilProcessing becomes a credible alternative to debouncing, with no artificial delay. The first edit triggers processing right away. Later ones, if they arrive during that processing, trigger a new run that will reload the final state. The price: more runs than with a debounce, but no added latency.

When per-object debouncing is no longer enough

The debounceId groups dispatches for the same object. A hundred products edited at the same time by an import means a hundred indexing runs, even with a perfect debounce. Yet a search engine indexes a hundred documents in one go far better than one document a hundred times.

For that case, you have to change approach: each edit adds the product ID to a set (a Redis SADD, a table of products to reindex), and a single job, triggered on a schedule or itself debounced with a fixed key, drains the set and indexes in batches. You're no longer debouncing events, you're accumulating changes. It's more code, but it's what holds up under a massive import.

Testing a debounce

One last point, about how the exercise tests it. Queue::fake() would be useless here: it records dispatches without running them, whereas all the behavior happens at execution time. So the exercise's tests use a real queue (the database driver), move the clock forward and start a real worker:

foreach ($edits as [$productId, $name]) {
    ProductUpdated::dispatch($productId, $name);
    $this->travel(2)->seconds();
}
$this->travel(60)->seconds();
Artisan::call('queue:work', ['--stop-when-empty' => true, '--sleep' => 0]);

Then they check what the system produced: one indexing run per product, with the latest name. It's slower than a fake, but it's the only way to test what actually matters. In production, I also recommend listening for JobDebounced to count discarded jobs: if that counter stays at zero, the debounce is pointless. If it's very high, the window may be too long for your users.

My takeaways

#[DebounceFor] fixes a genuine misuse: we were using ShouldBeUnique to absorb bursts, and indexing the wrong version. The new attribute does what we actually wanted, in a few lines.

But a debounce is still an optimization, not a guarantee. It doesn't shrink the queue, it depends on the cache, it adds latency, and it lets duplicates through when the cache falters. Robustness comes from elsewhere: processing that reloads the state and can run twice without harm. The debounce then makes that processing cheaper.

The exercise runs in the browser, with a real Laravel 13 and a real queue: you see the ShouldBeUnique bug, then its fix.

← All articles