June 28, 2026
<- Back to BlogRate Limiting and Backpressure for Multi-Tenant APIs
How to design rate limits, queue-based backpressure, and circuit breakers across tenants with very different traffic profiles — without throttling your biggest customer to protect your smallest.
Overview
A single global rate limit is the laziest and most common backend mistake. It treats a noisy free-tier user the same as an enterprise tenant doing 5,000 RPS. This post covers a layered approach: per-tenant quotas, per-endpoint budgets, queue-based backpressure, and circuit breakers for downstream dependencies.
Prerequisites
- A backend that can identify tenants at the edge (JWT claim, API key, header)
- A fast in-memory store (Redis, Dragonfly, or in-process token buckets)
- A queue with visibility timeouts (SQS, NATS JetStream, Kafka, Postgres SKIP LOCKED)
- Metrics + alerting on queue depth and circuit state
The Problem We Solved
A single noisy tenant (legitimately — they were migrating their entire fleet overnight) pushed p99 latency for everyone else from 200ms to 4s. Our "fair" global rate limit was the wrong tool: it would have throttled nine small tenants to protect one large one.
Layer 1: Per-Tenant Token Bucket
Every tenant gets a bucket sized to their plan. Use a leaky bucket or token bucket — token bucket handles bursts better for human-driven traffic.
import { Redis } from "ioredis";
const redis = new Redis();
async function takeToken(tenantId: string, route: string): Promise<boolean> {
const key = `rl:${tenantId}:${route}`;
const capacity = await getCapacityForPlan(tenantId); // e.g. 1000 burst
const refillRate = await getRefillRateForPlan(tenantId); // e.g. 100/sec
const now = Date.now();
const result = await redis.eval(
`local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last')
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
-- ... refill + take logic
return tokens_remaining`,
1, key, capacity, refillRate, now,
);
return result >= 0;
}
Store the bucket state in Redis with a TTL equal to 2× the refill window. Stale state is harmless — the bucket just resets.
Plan-Aware Buckets
Do not hardcode limits. Read from a config service that SEs can update per tenant without a deploy:
async function getCapacityForPlan(tenantId: string): Promise<number> {
const tenant = await controlPlane.getTenant(tenantId);
const plan = PLANS[tenant.plan];
return plan.burstCapacity ?? 100;
}
When a customer is hitting limits, an SE can bump their bucket in the control plane and see effect in seconds. No code change, no deploy, no escalation.
Layer 2: Per-Route Budgets
Some routes are expensive (POST /reports/generate), some are cheap (GET /healthz). A single per-tenant bucket hides this.
const ROUTE_CLASSES = {
"GET /v1/healthz": { weight: 0 }, // free
"GET /v1/users/:id": { weight: 1 },
"POST /v1/reports": { weight: 50 },
"POST /v1/imports": { weight: 200 }, // very expensive
};
Take tokens from the route's bucket, not the tenant's. A tenant on a low plan can still call cheap routes freely.
Layer 3: Queue-Based Backpressure
For genuinely expensive operations (report generation, bulk imports, ML inference), do not let them block the request thread. Enqueue, return a job ID, process async.
async function enqueueReport(tenantId: string, params: ReportParams) {
const jobId = ulid();
await db.transaction(async (tx) => {
await tx.query(
`INSERT INTO report_jobs (id, tenant_id, params, status)
VALUES ($1, $2, $3, 'queued')`,
[jobId, tenantId, JSON.stringify(params)],
);
});
await queue.send("report.generate", { jobId, tenantId });
return { jobId, status: "queued" };
}
The request thread returns in 10ms. The actual work happens off-thread. Queue depth becomes your backpressure signal:
- Depth < 1000: healthy.
- Depth 1000–5000: slow consumers, page on-call.
- Depth > 5000: refuse new jobs with
503 Service Unavailable, Retry-After: 30.
Per-Tenant Queue Fairness
A single queue means one tenant can starve others. Use per-tenant queues with a round-robin dispatcher:
while (true) {
const tenants = await getActiveTenantQueues();
const batch = [];
for (const t of tenants) {
const job = await t.queue.pop({ visibilityMs: 30_000 });
if (job) batch.push(job);
if (batch.length >= CONCURRENCY) break;
}
await Promise.all(batch.map(processJob));
}
Layer 4: Circuit Breakers for Downstream
A tenant calls your API; your API calls a vendor; the vendor goes down. Without a breaker, every request waits the full timeout, queue fills, your service falls over.
class CircuitBreaker {
private failures = 0;
private state: "closed" | "open" | "half-open" = "closed";
private openedAt = 0;
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.openedAt < this.cooldownMs) {
throw new Error("circuit open");
}
this.state = "half-open";
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
}
When the circuit opens, return a fast 503 with a Retry-After header. The customer's request fails cleanly instead of hanging. After the cooldown, allow one probe request (half-open); if it succeeds, close the circuit.
Breaker Per-Dependency
Different vendor APIs have different reliability profiles. One breaker per downstream, not one breaker for "external calls."
Layer 5: Customer-Facing Headers
Always return rate-limit state in the response:
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1719561600
Retry-After: 12
Customers build against these. They will warn themselves before they hit zero. Support tickets drop dramatically when you expose this.
Communicating Limits to Customers
SEs must be able to answer:
- "What is my current limit?"
- "What happens when I hit it?"
- "Can I get a temporary bump?"
- "How do I know I'm close?"
Ship a GET /v1/me/limits endpoint that returns the tenant's plan, current bucket state, and queue depth. SEs paste this into Slack; customers see it in their dashboard. The conversation shifts from "your API is broken" to "how do we tune our client."