Updated June 2026. Tested on Laravel 13 and PHP 8.4 with Redis queues and Horizon.
Picture a multi tenant app with one shared queue. Most of the time it hums along. Then one tenant kicks off a bulk import and dispatches forty thousand jobs in a single request. Every other tenant's jobs are now stuck at the back of that line, waiting for the import to clear. Their welcome emails, their exports, their webhooks, all delayed because of one noisy neighbour.
This is the queue fairness problem, and it shows up in every app that processes background work for more than one customer. There is no single fix, but there are a few solid strategies, each with its own trade offs. Let us walk through them from simplest to most involved. If your app is multi tenant in the first place, you may also want the wider picture in Multi-Tenancy in Laravel.
Why a single queue is unfair
A queue is first in, first out. Workers pull jobs in the order they were pushed. So when one tenant pushes a huge burst, those jobs physically sit ahead of everyone else's, and there is no notion of "your turn" built in. Adding more workers helps throughput, but it does not make the order fair: the burst still gets served first. To get fairness you have to change either how jobs are spread across queues, or how fast any one tenant is allowed to run.
Strategy 1: shuffle sharding
This idea comes from Mike Perham, the author of Sidekiq. Instead of one queue, you create several, say jobs_1 through jobs_8, and run at least two workers on each. When a tenant dispatches a job, it goes to a random one of those queues.
ProcessReport::dispatch($report)
->onQueue('jobs_'.random_int(1, 8));
Now when a tenant fires off a bulk of jobs in one request, they all land in one random queue. The other seven queues keep flowing with everyone else's work, so the burst only slows down whoever happens to share that one shard. It is a big improvement for very little code.
The limitation is that it only spreads a burst that comes from a single request. A genuinely busy tenant dispatching from many requests or processes at once will, by chance, spray jobs across all the shards and fill them anyway. Shuffle sharding softens spikes, it does not cap a sustained heavy tenant.
Strategy 2: rate limit each tenant
If the real problem is one tenant running too much for too long, cap how many of their jobs run per minute and let everyone else flow normally. Laravel ships job middleware for exactly this. Build a per tenant limiter and release the job back to the queue when the tenant is over budget.
// app/Jobs/Middleware/ThrottlePerTenant.php
namespace App\Jobs\Middleware;
use Illuminate\Support\Facades\Redis;
class ThrottlePerTenant
{
public function handle(object $job, callable $next): void
{
// Allow each tenant 30 jobs every minute. Anyone over the
// limit is released back to the queue and retried shortly.
Redis::throttle('tenant:'.$job->tenantId)
->allow(30)
->every(60)
->then(
fn () => $next($job),
fn () => $job->release(10),
);
}
}
// In the job itself
public function middleware(): array
{
return [new ThrottlePerTenant];
}
A tenant who floods the queue still only runs thirty jobs a minute, so they can never starve the rest. Their own jobs simply take longer to drain, which is the fair outcome. The cost is that released jobs cycle back through the queue, so set a sensible retry delay and make sure your retry_after and attempt limits account for it.
Strategy 3: a queue per tenant
For strong isolation, give every tenant their own queue and have workers pick a tenant queue at random on each loop.
// Dispatch onto the tenant's own queue
SendInvoice::dispatch($invoice)->onQueue('tenant_'.$tenant->id);
# A worker that picks a random tenant queue each cycle is driven by
# a small command that shuffles the active tenant queue names.
php artisan queue:work redis --queue=tenant_42,tenant_17,tenant_8 --max-jobs=50
Because each tenant's jobs sit in their own queue, one tenant's burst cannot get in front of another's. This gives the cleanest fairness of the lot. The downsides are real though. A worker may check several empty queues before it finds one with work, which wastes loops. And because the list of queues is dynamic, Horizon cannot auto balance the worker pool for you, so you have to run a fixed set of workers and manage them yourself.
Strategy 4: fixed queues with Horizon auto balancing
This is the middle ground, and for many apps the sweet spot. Keep a small, fixed set of queues, and send each tenant's jobs to one queue chosen by hashing the tenant id, so a given tenant always lands on the same queue. Then let Horizon auto balance workers across those queues.
// Always route a tenant to the same fixed queue
$queue = 'jobs_'.(crc32((string) $tenant->id) % 8);
ExportData::dispatch($export)->onQueue($queue);
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['jobs_0', 'jobs_1', 'jobs_2', 'jobs_3',
'jobs_4', 'jobs_5', 'jobs_6', 'jobs_7'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 20,
'tries' => 3,
],
],
],
When a tenant pushes a lot of jobs, it fills the one queue it hashes to, Horizon notices that queue getting deep, and it spins up more workers to drain it. Other queues keep their own workers, so the busy tenant gets the extra capacity without robbing everyone else. You get most of the isolation of a queue per tenant, but Horizon still manages the pool for you and there are no empty queue loops.
Which one should you reach for
Start with the cheapest fix that solves your actual pain.
- If your trouble is occasional bursts from one request, shuffle sharding is almost free and probably enough.
- If a few heavy tenants run sustained loads, add per tenant rate limiting so no one can starve the rest.
- If you need hard isolation and are happy to manage workers, go a queue per tenant.
- If you want isolation plus Horizon doing the scaling, use fixed hashed queues with auto balancing.
These also combine well. A common production setup is fixed hashed queues under Horizon for fair scaling, plus a per tenant rate limit middleware as a backstop so no single customer can ever run away with the workers.
Wrapping up
A single shared queue is fair until it is not, and the day one tenant floods it is the day your other customers feel it. Spread bursts with shuffle sharding, cap heavy tenants with job middleware, isolate fully with a queue per tenant, or get the best of both with fixed queues and Horizon auto balancing. Pick the lightest option that fixes your real problem, and layer a rate limit underneath as insurance.
How are you handling queue fairness in your own multi tenant app? Let me know in the comments.
All comments ()
No comments yet
Be the first to leave a comment on this post.