System design
Part 6 of 6 · Rate limitingFairness, Quotas & Noisy Neighbors
A single global RPS limit protects the servers but not the tenants: whoever sends the most wins the budget, so one batch job can push every other customer into 429s. Fairness needs two layers. Admission control gives each tenant its own quota on each scarce dimension (rate, concurrency, burst, usage per billing period), so a 429 hits only the tenant over its quota. Scheduling then shares the workers among admitted requests with weighted fair queuing (in practice deficit round robin), with a strict priority lane for critical traffic, so a deep queue from one tenant cannot starve the rest.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Overview
A single global RPS limit protects the servers but not the tenants. Whoever sends the most wins the budget, so one batch job can push every other customer into 429s. Fairness needs admission and scheduling.
By the end you should be able to:
- Separate a system cap from a per-tenant quota
- Pick rate, concurrency, cost, or a billing-period usage quota
- Walk deficit round robin in one minute
- Put health checks in a strict priority lane
- Say what you measure so a noisy neighbor does not look like a general slowdown
Why it matters
Interview signal. Multi-tenant SaaS questions almost always reach "what if one customer is huge?". Separate admission (quotas) from scheduling (fair queuing), pick the quota dimension, and explain how tiers map to weights.
Production signal. Noisy neighbors show up as incidents where one customer's import job raises everyone's p99, or where a free-tier abuser exhausts a shared limit and paying customers see 429s. Per-tenant metrics are the only way to see it.
Core concepts (deep)
Why a global limit is not fair. A global limiter admits the first N requests per window regardless of who sent them. A tenant that bursts first, or simply sends more, takes most of the budget. Others are rejected for capacity they never used. The global limit is still useful as a last line of defense for the system, just not as the fairness mechanism.
Per-tenant quotas. Key the limiter by tenant (or API key, or user), never by IP alone: NAT, mobile carriers, and corporate proxies put many tenants behind one address. Each tier gets its own numbers, for example free 10 req/s and enterprise 1000 req/s. Quotas can be hierarchical: a tenant budget, and inside it per-user or per-endpoint budgets.
Pick the scarce dimension. Request rate (token bucket per tenant) fits cheap uniform requests. Concurrency (max in-flight per tenant) fits slow or uneven requests, because ten 30-second reports cost more than a thousand 5 ms reads. Cost-weighted limits charge each request its cost (rows scanned, tokens generated, bytes). Usage quotas per billing period cover storage and monthly API calls. Algorithm choice for the bucket itself is token vs leaky vs sliding.
Weighted fair queuing. After admission, requests wait for shared workers. A single FIFO queue lets whoever enqueued first take every worker. Fair queuing keeps one lane per tenant and serves lanes in turn. Weighted fair queuing gives higher tiers more turns. Deficit round robin (Shreedhar and Varghese, 1995) is the practical version: each round a lane earns credits equal to its weight times a quantum and spends them per request or per unit of cost. It is work conserving: when other lanes are empty, a busy tenant may use the spare capacity.
Priority lanes and shedding. Health checks, auth, and control-plane calls go in a strict priority lane served before tenant lanes, so overload never blocks the requests that keep the system alive. Under severe overload, shed the lowest-priority work first. Stripe sheds non-critical traffic. Kubernetes API Priority and Fairness uses priority levels plus fair queuing per flow.
Isolation beyond rate limits. Very large tenants may need their own shard, cell, or worker pool so their failures and hot keys stay contained. Shuffle sharding assigns each tenant to a small random subset of workers, so two tenants rarely share all of their workers and one bad tenant affects only a few others. Where that limiter runs is gateways. The deny itself is still a 429.
Wrong pick, both ways. One global RPS limit only: the noisy neighbor starves everyone else. Strict per-tenant partitioning of workers with no sharing: fair, but idle capacity goes unused and small tenants cannot burst even when the system is quiet. Weighted fair queuing sits between them.
Tenant A at 950 req/s, Tenant B at 100, global cap 1000
Prefer
Per-tenant quotas plus deficit round robin
A 429 hits only the tenant over its quota. Workers are shared by tier weight, and a critical lane never waits behind batch work.
- Key is tenant or API key, not IP.
- DRR is work conserving: an idle neighbor does not waste the fleet.
- Health checks sit in a strict priority lane.
Alternative
One global counter, then one FIFO queue
Whoever arrives first takes the budget and then every worker. Tenant B pays for Tenant A's spike.
- B loses 50 requests under a global 1000 even though B only sent 100.
- A per-tenant quota in front of a shared FIFO still lets A's backlog occupy the workers.
- Strict worker partitions waste idle capacity.
Happy path: per-tenant quota plus weighted fair queuing
The six steps match Diagram 1. Steps 4a/4b are the two branches inside step 4.
- 1
Tag tenant and tier
The gateway tags the request with tenant_id and tier from the authenticated API key, never from the client IP. - 2
Check every quota dimension
Rate (token bucket), concurrency (in-flight count), and burst size. A miss on any dimension is a deny. - 3
Within quota or not
Inside every quota goes to 4b. Otherwise go to 4a. - 4
429 that tenant, or enqueue the lane
4a replies 429 for that tenant only, with Retry-After. Other tenants are unaffected. 4b enqueues the request in that tenant's lane. - 5
Weighted fair schedule
Serve the critical lane first, then tenant lanes by tier weight using deficit round robin. - 6
Worker serves the request
A worker runs it and the tenant's in-flight count goes down. With one global RPS limit only, a noisy tenant eats the shared budget.
Diagrams - step by step
Three small diagrams for fairness and quotas. Step numbers in the labels give the animation order. The lesson map under Diagram 1 plays those steps.
Diagram 1 - Happy path: per-tenant quota plus weighted fair queuing
Decisions
- 1
Step 1 Request tagged with tenant_id and tier
- nextStep 2 Check the tenant quota - RPS, concurrency, burst
- 2
Step 2 Check the tenant quota - RPS, concurrency, burst
- nextStep 3 Within quota?
- one global RPS limit onlyFailure path - a noisy tenant eats the shared budget
- ?
Step 3 Within quota?
- noStep 4a 429 for that tenant only
- yesStep 4b Enqueue in the tenant lane
- 4
Step 4a 429 for that tenant only
- 5
Step 4b Enqueue in the tenant lane
- nextStep 5 Weighted fair scheduler - critical lane first, then lanes by tier weight
- 6
Step 5 Weighted fair scheduler - critical lane first, then lanes by tier weight
- nextStep 6 Worker serves the request
- 7
Step 6 Worker serves the request
- 8
Failure path - a noisy tenant eats the shared budget
Quotas are per tenant and per dimension, so one tenant hitting its limit does not affect others. The scheduler then shares capacity by tier weight, with a priority lane for critical traffic.
Lesson map
Fairness, Quotas & Noisy Neighbors
Diagram 1 walks 7 steps from Step 1 Request tagged with tenant_id and tier through Step 6 Worker serves the request.
Architecture. Step 1 Request tagged with tenant_id and tier Ready. Step 2 Check the tenant quota - RPS, concurrency, burst Ready. Step 3 Within quota? Ready. Step 4a 429 for that tenant only Ready. Step 4b Enqueue in the tenant lane Ready. Step 5 Weighted fair scheduler - critical lane first, then lanes by tier weight Ready. Step 6 Worker serves the request Ready. Failure path - a noisy tenant eats the shared budget Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Step 1 Request tagged with tenant_id and tier Ready"] B["Step 2 Check the tenant quota - RPS, concurrency, burst Ready"] C["Step 3 Within quota? Ready"] R["Step 4a 429 for that tenant only Ready"] D["Step 4b Enqueue in the tenant lane Ready"] E["Step 5 Weighted fair scheduler - critical lane first, then lanes by tier weight Ready"] G["Step 6 Worker serves the request Ready"] X["Failure path - a noisy tenant eats the shared budget Ready"] A -->|continues| B B -->|continues| C C -->|no| R C -->|yes| D D -->|continues| E E -->|continues| G B -->|one global RPS limit only| X
Diagram 2 - Failure path: noisy neighbor under a global limit
Sequence
- 1
Tenant A batch job → Global limiter 1000 per s
Step 1 sends 950 per s
- 2
Global limiter 1000 per s → Shared service
Step 2 admitted, the global budget is mostly used
- 3
Tenant B → Global limiter 1000 per s
Step 3 sends 100 per s of normal traffic
- 4
Global limiter 1000 per s → Tenant B
Step 4 50 of them get 429
- 5
Tenant A batch job
Step 5 Tenant B pays for the Tenant A spike
- 6
Tenant A batch job
Fix - per-tenant quotas plus weighted fair queuing
A raw global RPS limit protects the system but not the tenants. Whoever sends the most wins the budget, which is the noisy-neighbor problem.
Diagram 3 - Decision: quota dimension and scheduling
Decisions
- ?
Step 1 One global RPS limit only?
- yesOne global RPS limit
- noStep 2 Is request rate the scarce resource?
- 2
One global RPS limit
- Wrong pickNoisy neighbor starves others
- ?
Step 2 Is request rate the scarce resource?
- yesPer-tenant RPS quota
- noStep 3 Slots or connections?
- 4
Per-tenant RPS quota
- nextStep 4 Tiers share the same workers?
- ?
Step 3 Slots or connections?
- yesPer-tenant concurrency limit
- noUsage quota per billing period
- 6
Per-tenant concurrency limit
- nextStep 4 Tiers share the same workers?
- 7
Usage quota per billing period
- ?
Step 4 Tiers share the same workers?
- yesWeighted fair queuing by tier
- noSimple per-tenant buckets
- 9
Weighted fair queuing by tier
- 10
Simple per-tenant buckets
- 11
Noisy neighbor starves others
Product tiers map to quota dimensions: rate, concurrency, burst, and storage. When tiers share workers, weighted fair queuing keeps a big tenant from starving smaller ones. One global RPS limit is the tempting shortcut, and the noisy neighbor starves everyone else.
Working Python
Run python3 fairness.py (stdlib only, instant; simulates one second of traffic). The scenario is Diagram 2: Tenant A runs a 950 req/s batch burst, Tenant B sends 100 req/s.
"""Fairness for multi-tenant APIs: global limit vs per-tenant quota vs weighted fair queuing.
Run: python3 fairness.py (stdlib only, instant; simulates one second of traffic)
Scenario from Diagram 2: Tenant A runs a 950 req/s batch burst, Tenant B sends 100 req/s.
"""
from collections import deque
def arrivals():
"""A's batch lands first in the second, then B's normal traffic (the noisy-neighbor case)."""
return ["A"] * 950 + ["B"] * 100
def global_limit(reqs, limit=1000):
"""Wrong pick: one shared counter. First come, first served, regardless of tenant."""
served = {"A": 0, "B": 0}
for i, t in enumerate(reqs):
if i < limit:
served[t] += 1
return served
def per_tenant_quota(reqs, quota):
"""Step 2-4a of Diagram 1: each tenant has its own budget, so a 429 hits only that tenant."""
used = {t: 0 for t in quota}
admitted, rejected = [], {t: 0 for t in quota}
for t in reqs:
if used[t] < quota[t]:
used[t] += 1
admitted.append(t)
else:
rejected[t] += 1
return admitted, rejected
def fifo(queue, capacity):
"""One shared queue: whoever enqueued first gets the workers."""
served = {"A": 0, "B": 0}
for t in queue[:capacity]:
served[t] += 1
return served
def drr(queue, capacity, weights, quantum=1):
"""Step 5: deficit round robin, the practical form of weighted fair queuing.
Each round a lane earns quantum * weight credits and spends 1 credit per request
(use the request cost for uneven work). Empty lanes keep no credit, so idle tenants
cannot bank capacity and burst later.
"""
lanes = {t: deque() for t in weights}
for t in queue:
lanes[t].append(t) # Step 4b: enqueue in the tenant lane
deficit = {t: 0 for t in weights}
served = {t: 0 for t in weights}
while capacity > 0 and any(lanes.values()):
for t, w in weights.items():
if not lanes[t]:
deficit[t] = 0
continue
deficit[t] += quantum * w
while lanes[t] and deficit[t] >= 1 and capacity > 0:
lanes[t].popleft()
deficit[t] -= 1
served[t] += 1 # Step 6: a worker serves it
capacity -= 1
return served
if __name__ == "__main__":
reqs = arrivals()
print("global limit 1000/s ->", global_limit(reqs), "(B loses 50 to A's burst)")
admitted, rejected = per_tenant_quota(reqs, {"A": 600, "B": 300})
print("per-tenant quota A600 B300 -> rejected", rejected, "(429 for A only)")
print("workers 500/s, FIFO ->", fifo(admitted, 500), "(B starves behind A's queue)")
print("workers 500/s, DRR A1 B2 ->", drr(admitted, 500, {"A": 1, "B": 2}))
print("DRR, B idle this second ->", drr([t for t in admitted if t == "A"], 500, {"A": 1, "B": 2}),
"(work conserving: A may use the spare capacity)")Sample run on the box: global limit 1000/s gives A 950 and B 50 (B loses 50 to A's burst). Per-tenant quota A600 B300 rejects 350 for A and 0 for B. 500 workers FIFO gives A 500 and B 0. DRR weights A1 B2 gives A 400 and B 100. DRR with B idle gives A 500 (work conserving).
Working TypeScript
Run npx tsx fairness.ts (no dependencies, about 1 second). The scheduler serves a critical lane first, then deficit round robin by tier weight, with a per-tenant concurrency cap, and compares that to one FIFO queue.
/**
* Fair scheduler for a shared worker pool: critical lane first, then deficit round robin by
* tier weight, plus a per-tenant concurrency cap. Compared against one FIFO queue.
* Run: npx tsx fairness.ts (no deps, about 1 s)
*/
type Job = { tenant: string; enqueued: number; run: () => Promise<void> };
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
class FairScheduler {
private lanes = new Map<string, Job[]>();
private deficit = new Map<string, number>();
private inflight = new Map<string, number>();
private busy = 0;
constructor(
private workers: number,
private weights: Record<string, number>, // tier weight per tenant
private maxConcurrent: number, // per-tenant concurrency quota
private critical = new Set<string>(), // health checks, auth: never wait behind batch
private fair = true, // false = one shared FIFO queue
) {}
submit(tenant: string, run: () => Promise<void>): void {
const key = this.fair ? tenant : "fifo";
if (!this.lanes.has(key)) this.lanes.set(key, []);
this.lanes.get(key)!.push({ tenant, enqueued: performance.now(), run }); // Step 4b
this.pump();
}
private pick(): Job | undefined {
if (!this.fair) return this.lanes.get("fifo")?.shift();
for (const t of this.critical) { // Step 5: critical lane first
const j = this.lanes.get(t)?.shift();
if (j) return j;
}
// Deficit round robin: top up every eligible lane until one can afford a job.
for (let round = 0; round < 1000; round++) {
for (const [t, q] of this.lanes) {
if (this.critical.has(t)) continue;
const capped = (this.inflight.get(t) ?? 0) >= this.maxConcurrent;
if (q.length === 0 || capped) { if (q.length === 0) this.deficit.set(t, 0); continue; }
const d = this.deficit.get(t) ?? 0;
if (d >= 1) { this.deficit.set(t, d - 1); return q.shift(); }
}
let topped = false;
for (const [t, q] of this.lanes) {
if (q.length && (this.inflight.get(t) ?? 0) < this.maxConcurrent) {
this.deficit.set(t, (this.deficit.get(t) ?? 0) + (this.weights[t] ?? 1));
topped = true;
}
}
if (!topped) return undefined; // everyone is empty or at their concurrency cap
}
return undefined;
}
private pump(): void {
while (this.busy < this.workers) {
const job = this.pick();
if (!job) return;
this.busy++;
this.inflight.set(job.tenant, (this.inflight.get(job.tenant) ?? 0) + 1);
job.run().finally(() => { // Step 6: worker serves it
this.busy--;
this.inflight.set(job.tenant, this.inflight.get(job.tenant)! - 1);
this.pump();
});
}
}
}
async function scenario(fair: boolean) {
const s = new FairScheduler(4, { A: 1, B: 2 }, 3, new Set(["health"]), fair);
const waits: Record<string, number[]> = { A: [], B: [], health: [] };
const done: Promise<void>[] = [];
const add = (tenant: string, ms: number) =>
done.push(new Promise<void>((resolve) => {
const t0 = performance.now();
s.submit(tenant, async () => {
waits[tenant]!.push(performance.now() - t0); // queueing delay before a worker picked it
await sleep(ms);
resolve();
});
}));
for (let i = 0; i < 120; i++) add("A", 10); // Tenant A batch dumps 120 jobs at once
for (let i = 0; i < 12; i++) add("B", 10); // Tenant B: normal interactive load
add("health", 1); // one health check
await Promise.all(done);
const p50 = (xs: number[]) => xs.sort((a, b) => a - b)[Math.floor(xs.length / 2)]!.toFixed(0);
console.log(`${fair ? "fair" : "fifo"}: wait p50 A=${p50(waits.A!)}ms B=${p50(waits.B!)}ms health=${p50(waits.health!)}ms`);
}
await scenario(false);
await scenario(true);Sample run on the box: FIFO wait p50 A=152 ms, B=316 ms, health=337 ms. Fair wait p50 A=223 ms, B=31 ms, health=10 ms. Fair scheduling cut Tenant B's median wait about 10x, and the health check no longer waits behind the batch. tsc --strict: OK.
Interview Q&A
One customer's nightly import makes every other customer slow. What do you change?
Answer
Add per-tenant admission limits so the import is throttled on its own quota, then replace the shared FIFO with per-tenant lanes and weighted fair queuing so its backlog cannot occupy every worker. Long term, move very large tenants or batch traffic to a separate pool.
Rate limit or concurrency limit per tenant?
Answer
Rate limits fit cheap, uniform requests. Concurrency limits fit slow or variable work, because they cap how many workers a tenant holds at once, regardless of how long each request takes. Many systems use both: a rate limit at the gateway and a concurrency limit in front of the expensive backend.
How do product tiers map to the mechanism?
Answer
Tiers set the quota numbers (free 10 req/s, pro 100, enterprise 1000) and the scheduler weights (for example 1, 2, 4). Contracts may add a reserved minimum for enterprise customers, which is a guaranteed share rather than just a higher weight.
Explain deficit round robin in one minute.
Answer
Keep one queue per tenant. Each round, every non-empty queue earns quantum x weight credits. It serves requests while it has enough credit for the next request's cost and keeps the remainder for the next round. Empty queues reset to zero so nobody banks credit while idle. The result is weighted fair sharing with O(1) work per request.
Why not key the limiter by IP address?
Answer
Many tenants share an IP behind NAT, mobile carrier gateways, and corporate proxies, so one heavy user gets the whole office throttled. And one tenant can spread across many IPs. Use the authenticated identity. Keep IP limits only at the edge for unauthenticated abuse.
How do you know fairness is working in production?
Answer
Emit per-tenant metrics: admitted, rejected, queue wait, in-flight, and latency percentiles. Alert when one tenant's share of capacity jumps, and when small tenants' p99 rises while a large tenant is active. Without per-tenant breakdowns a noisy-neighbor incident looks like a general slowdown.
What does a Kubernetes API server do about noisy clients?
Answer
API Priority and Fairness classifies requests into priority levels, gives each level a share of the server's concurrency, and within a level uses fair queuing across flows (for example per user), with shuffle sharding of queues. One misbehaving controller cannot starve kubectl or the kubelets.
Pros and cons
| Approach | Pros | Cons |
|---|---|---|
| One global RPS limit | Simple. Protects the system from total overload. | Not fair. The largest or fastest sender wins. Small tenants see 429s for capacity they never used. |
| Per-tenant quotas (rate, concurrency, usage) | A 429 hits only the tenant over its quota. Maps directly to product tiers and contracts. | Needs reliable tenant identity and per-tenant state. Quotas alone do not order the work already admitted. |
| Quotas plus weighted fair queuing (DRR) and a priority lane | Fair sharing of workers by tier. Work conserving. Critical traffic never waits behind batch work. | More moving parts. Weights and costs need tuning. Per-tenant queues need memory limits. |
Pitfalls
Tenant A sends 950 and Tenant B sends 100 against a global 1000. Write what each gets under a global limit, under quotas A600 and B300, under FIFO with 500 workers, and under DRR weights 1 and 2. If you keyed the quota on IP, start over.
Go Deeper
- AWS Builders Library: Fairness in multi-tenant systems
- Kubernetes: API Priority and Fairness
- Stripe: Scaling your API with rate limiters
- Google SRE book: Handling overload
- Deficit round robin (Wikipedia)
- Weighted fair queueing (Wikipedia)
Related
- Rate Limiting: Token Bucket, Leaky Bucket & Sliding Window (
rate-limiting) - Token Bucket vs Leaky Bucket vs Sliding Window (
token-leaky-sliding-window) - Redis + Lua Atomic Rate Limiters (
redis-lua-atomic-rate-limiters) - Distributed Rate Limits Across Gateways (
distributed-rate-limits-gateways) - HTTP 429, RateLimit Headers & Retry-After (
http-429-ratelimit-headers)
Prev / Next
- Prev: HTTP 429, RateLimit Headers & Retry-After (
http-429-ratelimit-headers) - Next: none (last page in the rate-limiting series)
- Hub: Rate Limiting: Token Bucket, Leaky Bucket & Sliding Window (
rate-limiting)