Back to BlogCloud

Your Job Queue Has a Noisy Neighbor Problem

Single shared FIFO queues are fair to jobs, not tenants. Learn how one spiky tenant silently starves everyone else, and how per-tenant virtual queues with fair scheduling fix the root cause without just throwing more workers at it.

queuesmulti-tenancysystem designworkersfair scheduling
Your Job Queue Has a Noisy Neighbor Problem

Your Job Queue Has a Noisy Neighbor Problem

The first version of every background job system looks roughly the same: a table in your database, a status column, and a SELECT ... FOR UPDATE SKIP LOCKED that workers poll. Or maybe Redis with LPUSH/BRPOP. Either way, it's a single FIFO queue shared by every tenant.

This works fine when you have one tenant, or ten. It stops working the moment one tenant submits 100,000 jobs at 9 AM on a Monday.

The Mistake: FIFO Is Fair to Jobs, Not Tenants

FIFO queues are fair to *jobs* — the job that arrives first gets processed first. They're deeply unfair to *tenants* — if Tenant A enqueues 100k image-resize tasks and Tenant B enqueues 50 invoice-generation tasks right after, every single one of Tenant A's jobs runs first.

Tenant B's deadline-sensitive invoices sit behind a wall of work they didn't cause and can't see. Depending on your worker count and job throughput, hours is not an exaggeration.

This isn't hypothetical. Stripe, Shopify, and any SaaS that processes user-triggered background work at scale has hit this. The failure mode is always the same shape: one spiky tenant poisons the queue for everyone else.

Why Adding More Workers Doesn't Fix It

The obvious reflex is to scale the worker pool. But more workers don't change queue ordering — they drain Tenant A's 100k jobs faster, and Tenant B still waits behind all of them.

If your work is database-bound (most job queues are), you hit connection pool limits or lock contention long before you close the fairness gap. You're paying more to be equally unfair, faster.

Vertical scaling is even worse. You're buying more capacity to execute the wrong scheduling policy at a higher rate.

The Better Architecture: Per-Tenant Virtual Queues

The fix is to separate *submission* from *scheduling*. Each tenant gets their own logical queue. A fair scheduler sits between those queues and the worker pool, picking the next job across tenants rather than from a single FIFO line.

With round-robin scheduling, the scheduler picks one job from Tenant A, one from Tenant B, one from Tenant C, then back to A. Tenant A's 100k jobs consume at most 1/N of the worker capacity at any moment, where N is the number of active tenants.

Layer weighted fair queuing on top if you have pricing tiers: premium tenants get a higher weight (3 slots per round vs 1). This is how cloud platforms like SQS with message group IDs, or Google Cloud Tasks with queue-level rate limits, actually work under the hood.

Implementation Patterns

You don't need a specialized queue platform to do this.

**Postgres with tenant-aware selection**

SELECT * FROM jobs
WHERE tenant_id = (
  SELECT tenant_id FROM jobs
  WHERE status = 'pending'
  ORDER BY last_dispatched_at ASC NULLS FIRST
  LIMIT 1
)
AND status = 'pending'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED

The sub-query picks the tenant dispatched least recently, then grabs their oldest pending job. This is poor man's fair queuing and it carries most SaaS products well past 1k jobs/second before you need anything more complex. Add an index on (status, last_dispatched_at) and it stays fast.

**Per-tenant Redis lists + a coordinator**

Each tenant gets a key like jobs:{tenant_id}. A coordinator process (or a Lua script, to keep selection atomic) round-robins across active tenant keys and pushes selected job IDs into a single dispatch list that workers BRPOP normally. Redis sorted sets let you encode priority and tenant into a composite score for more nuanced scheduling without a separate coordinator step.

**Managed platforms**

Temporal's task queues, Inngest's functions, and BullMQ's named queues all give you per-tenant isolation if you create one queue per tenant. The overhead is usually acceptable when your tenant count is bounded — hundreds, not millions.

Tradeoffs Worth Knowing

**Ordering guarantees change.** Global FIFO becomes FIFO *within each tenant*. Cross-tenant ordering disappears — but you almost never actually need it. That's usually an implicit assumption nobody verified when the system was built for a single customer.

**Latency distribution shifts in your favor.** With shared FIFO, a small tenant's P99 job latency equals however long the queue is when they submit. With fair scheduling, small tenants get something close to P50 of a single job's processing time. Small tenants win dramatically; large tenants are rate-limited. This is the correct trade for a multi-tenant SaaS.

**More operational state.** You now track per-tenant queue depth, dispatch timestamps, and weights. That's more surface area. But it also gives you actionable metrics: you can see exactly which tenants are backlogged, which ones are abusing capacity, and whether your SLAs are actually achievable. The alternative is a single queue depth number that tells you nothing about who caused it.

**Backpressure becomes explicit.** With a shared queue, you can see that the total backlog is growing but not why. With per-tenant queues, you can enforce limits at enqueue time — return a 429 when a tenant's queue depth exceeds a threshold. That's far better than silently accepting work that won't run for hours, and it creates a forcing function for tenants to submit at a sustainable rate.

Where to Start

If you're running SaaS with more than ~5 tenants submitting background work at variable rates, start with the Postgres sub-query approach. Three extra SQL lines, no new infrastructure, and it'll carry you further than you expect.

When you hit the limits of that — usually around 5-10k jobs/second, or when you need weighted tiers for different pricing plans — move to per-tenant Redis lists with a coordinator process.

The core DDIA insight that applies here: a queue is a buffer between producers and consumers, and its scheduling policy is a first-class design decision — not a default you inherit from your queue library. FIFO is the simplest policy. It is not the correct one for multi-tenant systems.