/ Content
Debouncing in frontend code means "wait for the user to stop typing before firing the request." Same idea applies to jobs. If updating a product triggers a search index rebuild, and you're importing 200 products at once, you don't want 200 reindex jobs in the queue. You want one. The most recent one.
Laravel 13 added #[DebounceFor] to handle this natively. Here's the basic setup:
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\DebounceFor;
#[DebounceFor(30)]
class UpdateSearchIndex implements ShouldQueue
{
use Queueable;
public function __construct(public int $productId) {}
public function debounceId(): string
{
return (string) $this->productId;
}
}
Dispatch this 15 times for the same product within 30 seconds and only the last dispatch actually runs. Every new dispatch kicks out the previous one and resets the 30-second window. When the dispatches stop and the window closes, the job runs once.
When a job gets superseded, Laravel fires an Illuminate\Queue\Events\JobDebounced event, which you can listen to if you need to track that or do any cleanup.
How it compares to unique jobs
If you've used ShouldBeUnique, this might sound familiar. Both are about deduplication, but they solve opposite problems.
With ShouldBeUnique, the first job wins. Subsequent dispatches are rejected while the first is pending. It protects the queue by saying "we already have one of these, don't add another."
With DebounceFor, the latest job wins. Each new dispatch replaces the previous one. Job #1 comes in at second 0 and gets queued. Job #2 arrives at second 10, job #1 is removed, job #2 takes its place, and the 30-second window resets. Job #3 arrives at second 25 of the new window, same thing. Eventually the dispatches stop and the last one runs.
They also differ on the interface requirement. Unique jobs must implement ShouldBeUnique or ShouldBeUniqueUntilProcessing. That interface requirement is what signals to the queue worker to check for duplicates. Debounced jobs have no such requirement. The #[DebounceFor] attribute is all you need. Both mechanisms use cache locking under the hood, but the entry points are different.
You can't use both at once. A job with DebounceFor should not implement ShouldBeUnique. Laravel will throw an exception if you do.
debounceId()
The debounceId() method is how Laravel decides whether two dispatches represent the same job for debouncing purposes.
Without it, all dispatches of a given job class are debounced together as a single window. For a singleton job that's fine. But for a job that operates on specific entities, you almost certainly want per-entity debouncing.
In the example above, debounceId() returns the product ID. That means UpdateSearchIndex for product 42 and UpdateSearchIndex for product 99 have completely independent debounce windows. An update to product 99 doesn't reset the window for product 42.
You can make the ID as specific as you need:
public function debounceId(): string
{
return "{$this->tenantId}:{$this->userId}:{$this->reportType}";
}
This string gets folded into the cache key that tracks the current pending dispatch, so it just needs to uniquely identify the "slot" you want to debounce against.
maxWait
Pure debouncing has a failure mode. In high-traffic situations, a job could theoretically never run if new dispatches keep arriving before the window closes. Picture a product receiving constant price feed updates. With a 30-second debounce, the search index might go minutes without ever reflecting the current price.
maxWait is the safety valve:
#[DebounceFor(30, maxWait: 120)]
class UpdateSearchIndex implements ShouldQueue
{
use Queueable;
public function __construct(public int $productId) {}
public function debounceId(): string
{
return (string) $this->productId;
}
}
Now the job debounces for 30 seconds, but will fire within at most 120 seconds from the first dispatch, even if updates keep arriving. The most recent dispatch at the point of execution wins.
This is the right default for any debounced job that handles real-time data. Without it you're relying on the dispatch frequency to naturally calm down. That's an assumption that will eventually be wrong.
Multiple debounced jobs can run simultaneously
This is the part that'll catch you if you're not expecting it.
Debouncing tracks the pending dispatch, not the running one. As soon as a job starts processing, the debounce lock is released. A new dispatch after that point opens a fresh window with no connection to the currently running job.
So the sequence can look like this:
- Job A dispatched. Queued with a 30-second window.
- Job A starts processing. Debounce lock released.
- While Job A is still running, Job B is dispatched. Job B gets its own fresh 30-second window.
- Job B's window closes before Job A finishes. Job B starts processing.
- Job A and Job B are now running simultaneously.
This is the same behavior as ShouldBeUniqueUntilProcessing. The lock is only held while the job is pending, not while it's running. ShouldBeUnique is the one that holds the lock through the entire execution, preventing concurrent runs. There's no equivalent for debounced jobs right now.
If your job reads some state, does some work based on that state, and then writes results, overlapping runs can produce race conditions. You'll want to either add your own locking inside handle() or design the job to be idempotent so that concurrent runs produce the same result.
Custom cache driver
By default debouncing uses whatever cache driver you've configured for the application. To be explicit about it:
use Illuminate\Contracts\Cache\Repository;
use Illuminate\Support\Facades\Cache;
public function debounceVia(): Repository
{
return Cache::driver('redis');
}
If you're running multiple web servers, they need to point at the same cache backend. Two servers with separate in-memory caches will each maintain independent debounce windows and can dispatch duplicate jobs. Redis is the right choice here.
Attribute inheritance
One current limitation worth knowing: #[DebounceFor] is not inherited by child classes. If you put the attribute on a base job and extend it, the child won't be debounced.
#[DebounceFor(30)]
class BaseUpdateJob implements ShouldQueue
{
use Queueable;
}
// Not debounced
class UpdateSearchIndex extends BaseUpdateJob
{
public function __construct(public int $productId) {}
public function handle(): void
{
// ...
}
}
The attribute needs to be on the concrete class. There's an open PR to address this, so it may be resolved by the time you're reading this. But as of writing, you'll need to repeat the attribute on each class that should be debounced.
With unique jobs you're in a similar position: ShouldBeUnique must appear on each concrete class, though the methods like uniqueId() can live on a parent. The difference here is that ShouldBeUnique is visible in the class signature, so it's obvious when a child class is missing it. With #[DebounceFor], there's no interface equivalent, so the only signal is the attribute itself. When a child class doesn't have it, there's nothing in the signature to tip you off that debouncing isn't active.
Until that PR lands, just put the attribute directly on every job that needs it. It's one line.