Idempotent handlers keep retries from double-charging a customer. They do not stop one noisy tenant from eating the whole worker pool. I have watched a single enterprise import enqueue 200k jobs, starve every other customer's webhooks, and turn a "shared queue" into an accidental denial of service—while every job was perfectly idempotent. Backpressure, fairness and tenant isolation are how you keep multi-tenant async work boring under load.

I wrote this with help from AI tools and checked every example and claim myself. At Loop Origin we build Laravel and Node SaaS systems where queues are part of the product surface, not an afterthought. This is Part 2 of Reliable Queues & Async Work in SaaS. Part 1 covered idempotent jobs and exactly-once effects. Here we make sure those safe jobs still get a fair turn.

📚 Series Roadmap — Reliable Queues & Async Work in SaaS

  • Part 1: Idempotent Jobs & Exactly-Once Effects: Designing Safe Retries
  • Part 2 (This Post): Queue Backpressure, Fairness & Tenant Isolation in Multi-Tenant SaaS
  • Part 3: Dead Letter Queues, Poison Messages & Safe Replay Playbooks
  • Part 4: Delayed Jobs, Schedulers & Clock Skew Gotchas
  • Part 5: Observability for Async Work: Correlation IDs & Job Traces
  • Part 6: Choosing Redis, SQS or Database Queues for Laravel and Node SaaS

Earlier Loop Origin series: Multi-Tier Caching & Stampede Protection · LLM Gateway & FinOps

How do you isolate noisy tenants on a shared job queue?

Direct answer: Cap how much work one tenant can enqueue or run at once, shard or weight queues so no single customer monopolizes workers, and apply backpressure at the API when limits are hit instead of accepting unbounded jobs. Fairness is a product requirement: one import should not delay everyone else's password-reset emails.

Network cabinet with ethernet and fiber patch cords, AI-generated

The failure mode: correct jobs, unfair runtime

Part 1 made retries safe. Unfairness looks different:

Symptom Likely cause Isolation lever
Webhooks lag while one import runs Shared queue, FIFO within one tube Per-tenant concurrency + separate queues
p99 job wait explodes for everyone Unbounded enqueue from one API Admit control / backpressure
Redis memory climbs, workers idle on huge payloads Fat jobs, no size limits Payload caps + object-store offload
"Fair" round-robin still starves small tenants One tenant owns 90% of ready jobs Weighted fair queueing or shards

This is the same spirit as tenant quotas on an LLM gateway: shared infrastructure needs explicit budgets.

Backpressure at the edge (stop accepting infinite work)

Workers are not the only place to throttle. Reject or delay admission when a tenant is over budget.

Laravel: per-tenant enqueue budget

// app/Services/JobAdmission.php final class JobAdmission { public function __construct(private RedisManager $redis) {} public function assertCanEnqueue(string $tenantId, string $jobType, int $cost = 1): void { $key = "qbudget:{$tenantId}:{$jobType}:".now()->format('YmdHi'); // per-minute $count = $this->redis->connection()->incrby($key, $cost); if ($count === $cost) { $this->redis->connection()->expire($key, 120); } $limit = config("queue.tenant_limits.{$jobType}", 200); if ($count > $limit) { throw new TooManyJobsException( "Tenant {$tenantId} exceeded {$jobType} enqueue budget" ); } } } 

Call it before ImportRows::dispatch(...). Return HTTP 429 (or a async "queued with delay" status) instead of silently burying the shared Redis list.

Node: token bucket before BullMQ add

import { RateLimiterRedis } from "rate-limiter-flexible"; const importLimiter = new RateLimiterRedis({ storeClient: redis, keyPrefix: "qbudget:import", points: 500, // jobs duration: 60, // per minute per tenant }); export async function enqueueImport(tenantId: string, payload: unknown) { await importLimiter.consume(tenantId); await importQueue.add("import", { tenantId, payload }, { jobId: `${tenantId}:${payload["idempotencyKey"]}`, // pairs with Part 1 }); } 

Idempotency keys from Part 1 still apply; admission control decides whether the first copy is allowed right now.

Fairness patterns that work in production

1. Queue-per-class (priority without starvation)

queues: critical # password resets, billing webhooks default # normal product work bulk # imports, reindexes, exports 

Laravel Horizon:

'environments' => [ 'production' => [ 'supervisor-critical' => [ 'connection' => 'redis', 'queue' => ['critical'], 'balance' => 'auto', 'maxProcesses' => 10, ], 'supervisor-default' => [ 'connection' => 'redis', 'queue' => ['default'], 'maxProcesses' => 20, ], 'supervisor-bulk' => [ 'connection' => 'redis', 'queue' => ['bulk'], 'maxProcesses' => 8, // capped on purpose ], ], ], 

Route tenant imports to bulk. Never let bulk workers outnumber critical ones during an incident.

2. Per-tenant concurrency

Even on bulk, cap how many jobs from one tenant run at once:

class ProcessImportChunk implements ShouldQueue, ShouldBeUnique { public function __construct( public string $tenantId, public string $chunkId, ) {} // Horizon / unique-for: one active chunk pipeline marker per tenant public function uniqueId(): string { return "import-active:{$this->tenantId}"; } public $uniqueFor = 3600; } 

For finer control, keep a Redis counter of in-flight jobs per tenant and re-queue with delay when over the cap. BullMQ's group / rate-group patterns (or a small custom limiter) solve the same problem in Node.

3. Shard by tenant hash (horizontal fairness)

bulk-0 ... bulk-15 where shard = hash(tenantId) % 16 

Each shard has its own workers. A hot tenant saturates one shard instead of all sixteen. Combine with per-tenant concurrency inside the shard.

Strategy Pros Cons
Single shared queue Simple Noisy-neighbor by default
Priority queues (critical/default/bulk) Protects user-facing work Bulk can still dominate its lane
Per-tenant concurrency Direct fairness Needs Redis bookkeeping
Tenant sharding Scales horizontally Hot shard still needs a cap
Weighted fair queueing Best fairness math Heavier to implement

Backpressure inside workers (slow down, do not melt)

When downstreams (Stripe, SMTP, your own Postgres) hurt, workers should shed load:

async function handle(job: Job) { if (await postgresIsSaturated() || await redisMemHigh()) { // Delay instead of hammering — pairs with stampede-safe caching ideas await job.moveToDelayed(Date.now() + 15_000, job.token); return; } await doWork(job.data); } 

The Redis stampede / singleflight patterns from the earlier series apply: thundering herds on retries make unfairness worse.

What to measure

If you cannot see tenant wait time, you cannot claim fairness:

  • Enqueue rate and reject/429 rate per tenant
  • Time-to-start (queue wait) p50/p99 per queue class
  • In-flight concurrency per tenant
  • Worker saturation and Redis list depth per shard

Alert when one tenant's in-flight share exceeds a threshold (for example 30% of bulk capacity) for more than a few minutes.

When we implement these controls on client projects—whether Laravel development or broader custom web application builds—we treat queue budgets as part of the product's multi-tenant contract, same as API rate limits.

Diagnostic checklist

  1. Can one tenant's import delay another tenant's critical email jobs?
  2. Do you have separate queues (or workers) for critical vs bulk work?
  3. Is there an enqueue budget (per minute/hour) enforced before dispatch / add?
  4. Is per-tenant concurrency capped on bulk lanes?
  5. Do 429/backpressure responses include a clear retry-after or job status URL?
  6. Are idempotency keys from Part 1 still required on every side-effecting job?
  7. Do dashboards break out queue wait by tenant or at least by shard?
  8. What happens when Redis memory or DB CPU hits a watermark—delay or death spiral?
  9. Are payload sizes capped so one fat job cannot block a worker for minutes?
  10. Have you load-tested "one noisy tenant + steady critical traffic" in staging?

FAQ

Is a single Redis queue enough for multi-tenant SaaS?

For early prototypes, yes. For production with uneven tenants, no—not without admission control and concurrency caps. Complexity should match blast radius.

Does Laravel ShouldBeUnique give me fairness?

It prevents duplicate active jobs for a key; it does not allocate capacity across tenants. Use it with Part 1 dedupe and separate fairness limits.

Should every tenant get a dedicated queue?

Usually too many queues. Prefer a few classes (critical/default/bulk) plus sharding or per-tenant caps. Dedicated queues are for a handful of platinum tenants, not thousands.

How is this different from API rate limiting?

API limits protect request handlers. Queue budgets protect async capacity. You need both; otherwise clients can pass the HTTP limiter and still flood workers via "accepted" jobs.

What about SQS fair queues / vendor features?

Use them when you are on that broker (Part 6). The product rules—caps, classes, noisy-neighbor tests—stay the same regardless of Redis vs SQS vs database queues.

Where do poison messages fit?

Part 3. Fairness keeps tenants from starving each other; DLQs keep bad payloads from infinite-retrying inside a fair share.

Next in this series, Part 3 covers dead letter queues, poison messages and safe replay playbooks so failed jobs do not become a second outage.

Follow & Connect