Backpressure and Load Shedding
Bound every queue, shed the work worth least, and degrade on purpose instead of falling over at random.
W = L / λ // Little's Law: queue wait = items queued / arrival rateBackpressure is the signal that travels back up a pipeline when a stage cannot keep up: slow down, or I start refusing. Load shedding is what you do when nothing upstream slows down. You drop work deliberately, choosing which work, rather than letting the system choose for you by collapsing.
Queues are where latency hides
Little's Law, L = λW, says the number of items in a system equals the arrival rate times the time each spends there. Rearranged as W = L / λ, 5,000 queued requests at 500 arrivals a second means every new arrival waits ten seconds before work even starts. An unbounded queue does not absorb overload, it stores it, and it stores it where your error-rate dashboard cannot see: availability reads 100% while every client times out.
Bound it, then shed
Give every buffer a size, and make full mean reject. Bounding by age is better. Facebook's "Fail at Scale" describes a CoDel variant that gives a request a short timeout (M, 5 ms) when the queue has not been empty for a while (N, 100 ms) and a generous one otherwise, so a queue that drains stays fast and a queue that never drains sheds. They pair it with adaptive LIFO: once a queue forms, serve the newest request first, because the ones at the front have been waiting longest and are likeliest to have been abandoned already.
Shed by value
Requests are not worth the same. Google's RPC stack tags each with a criticality — CRITICAL_PLUS, CRITICAL, SHEDDABLE_PLUS, SHEDDABLE — and backends drop the cheap ones first. Stripe reserves a share of fleet capacity for critical calls and returns 503 to non-critical traffic over its allowance.
Example
// An admission queue with CoDel-style age-based shedding and adaptive LIFO,
// after Facebook's "Fail at Scale" (Maurer, 2015).
const M_MS = 5; // short timeout, used once the queue is persistently busy
const N_MS = 100; // how long we tolerate a continuously non-empty queue
class AdmissionQueue {
constructor({ concurrency, maxDepth }) {
this.concurrency = concurrency;
this.maxDepth = maxDepth; // hard bound: the last line of defence
this.inFlight = 0;
this.queue = [];
this.lastEmptyAt = Date.now();
}
// The CoDel rule: if the queue HAS been empty recently we are coping, so
// allow a generous wait. If it has not, we are overloaded: expire fast.
timeoutFor(now) {
return this.lastEmptyAt < now - N_MS ? M_MS : N_MS;
}
submit(task) {
const now = Date.now();
if (this.queue.length >= this.maxDepth) {
return Promise.reject(Object.assign(new Error('overloaded'), { status: 503 }));
}
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject, expiresAt: now + this.timeoutFor(now) });
this.pump();
});
}
next() {
const now = Date.now();
// Drop everything that has already expired BEFORE picking work. These
// are requests whose callers have given up; running them is pure waste.
this.queue = this.queue.filter((item) => {
if (item.expiresAt > now) return true;
item.reject(Object.assign(new Error('queue timeout'), { status: 503 }));
return false;
});
if (this.queue.length === 0) { this.lastEmptyAt = now; return null; }
// Adaptive LIFO: FIFO while the queue is shallow (fairness), LIFO once it
// builds up, because the newest request has the best chance of finishing
// inside its deadline.
const backedUp = this.queue.length > this.concurrency;
return backedUp ? this.queue.pop() : this.queue.shift();
}
async pump() {
while (this.inFlight < this.concurrency) {
const item = this.next();
if (!item) return;
this.inFlight++;
try {
item.resolve(await item.task());
} catch (err) {
item.reject(err);
} finally {
this.inFlight--;
}
}
}
}
// Concurrency from Little's Law, depth from the latency you are willing to
// add: 64 slots at ~20ms means 256 queued is already ~80ms of pure waiting.
const admissions = new AdmissionQueue({ concurrency: 64, maxDepth: 256 });When to use it
- Sizing a worker pool's queue so that a traffic spike adds bounded latency instead of unbounded latency.
- Keeping checkout alive during a spike by dropping analytics and prefetch traffic first.
- Explaining why adding queue depth made p99 worse rather than better.
More examples
Shedding by criticality, not at random
Reach for this the first time a traffic spike takes down checkout along with everything else. The useful part is that the ordering is decided in advance and written down, so degradation is a design decision rather than an accident of arrival order. Two details carry weight: the status code, since 503 says the server is over capacity while 429 says this caller is over quota and clients treat them differently, and the counter, because shedding silently converts an availability problem into an invisible one.
// Express middleware that sheds the cheapest work first, using Google's
// criticality ladder as the ordering and a local health signal as the input.
const CRITICALITY = {
CRITICAL_PLUS: 0, // user-visible and unrecoverable: a payment
CRITICAL: 1, // user-visible: loading the page
SHEDDABLE_PLUS: 2, // batch work that tolerates partial failure
SHEDDABLE: 3, // prefetches, analytics beacons, speculative work
};
function criticalityOf(req) {
if (req.method === 'POST' && req.path.startsWith('/checkout')) return CRITICALITY.CRITICAL_PLUS;
if (req.get('x-request-source') === 'batch') return CRITICALITY.SHEDDABLE_PLUS;
if (req.path.startsWith('/beacon')) return CRITICALITY.SHEDDABLE;
return CRITICALITY.CRITICAL;
}
// Shed from the bottom up as pressure rises, so the system degrades in a
// known order instead of dropping whatever happened to arrive during a spike.
function sheddingLevel() {
const lagMs = loopDelayHistogram.percentile(99) / 1e6;
if (lagMs > 500 || admissions.queue.length > 240) return CRITICALITY.CRITICAL;
if (lagMs > 200 || admissions.queue.length > 160) return CRITICALITY.SHEDDABLE_PLUS;
if (lagMs > 100 || admissions.queue.length > 80) return CRITICALITY.SHEDDABLE;
return 99; // shed nothing
}
app.use((req, res, next) => {
const level = sheddingLevel();
if (criticalityOf(req) >= level) {
shedCounter.inc({ path: req.path }); // ALWAYS count what you shed
// 503 with Retry-After: this is overload, not a client error, so do not
// use 429 here. 429 means "you specifically are over your limit".
return res.set('Retry-After', '2').status(503).json({ error: 'overloaded' });
}
next();
});Client-side adaptive throttling
With K = 2 a client keeps sending while the backend accepts at least half of what it offers, and self-throttles smoothly past that, so a struggling backend sees offered load fall as it rejects rather than rising. Use it when many clients share one backend and rejections are cheap but not free, which is most internal RPC. The classifier in the catch block is the part to get right: counting every error as a rejection makes a bad query from one client throttle traffic that was working.
// Shedding at the server still costs the server a request. Google's SRE book
// pushes part of the job into the client: once a backend starts rejecting,
// the client rejects locally and the request never leaves the process.
const WINDOW_MS = 120_000; // the book uses a 2-minute window
const K = 2; // recommended multiplier
class AdaptiveThrottle {
constructor() { this.events = []; }
prune(now) {
const cutoff = now - WINDOW_MS;
while (this.events.length && this.events[0].at < cutoff) this.events.shift();
}
record(accepted) { this.events.push({ at: Date.now(), accepted }); }
// P(reject) = max(0, (requests - K * accepts) / (requests + 1))
rejectionProbability() {
const now = Date.now();
this.prune(now);
const requests = this.events.length;
const accepts = this.events.reduce((n, e) => n + (e.accepted ? 1 : 0), 0);
return Math.max(0, (requests - K * accepts) / (requests + 1));
}
async call(fn) {
if (Math.random() < this.rejectionProbability()) {
// Local rejection: no connection, no backend work, no queue slot.
throw Object.assign(new Error('throttled locally'), { code: 'CLIENT_THROTTLED' });
}
try {
const out = await fn();
this.record(true);
return out;
} catch (err) {
// A backend rejection (429/503) is NOT an accept. A 404 is: the backend
// did the work and answered, so it must not throttle us.
this.record(!(err.status === 429 || err.status === 503));
throw err;
}
}
}Stream backpressure, shorthand and longhand
Both forms are the same mechanism, and knowing the long one is what lets you recognise its absence: a loop that calls write() without checking the return value is an unbounded in-memory queue, which is the same bug as an unbounded request queue with the same symptom, rising memory and rising latency while the error rate stays flat. Write the pipeline form in new code; read the long form when you are hunting a memory leak in an export job.
// Node streams have backpressure built in, and the shorthand hides it.
// LONGHAND: writable.write() returns false when the buffer is past
// highWaterMark; you must stop and wait for 'drain'.
function writeAll(rows, out, done) {
let i = 0;
(function pump() {
while (i < rows.length) {
const ok = out.write(JSON.stringify(rows[i++]) + '\n');
if (!ok) {
// Ignoring this return value is the classic leak: the loop keeps
// buffering in memory at the speed of the producer, not the consumer.
out.once('drain', pump);
return;
}
}
out.end(done);
})();
}
// SHORTHAND: pipeline() does exactly the same thing, plus error and cleanup
// propagation in both directions. stream/promises is Node v15.0.0+.
const { pipeline } = require('node:stream/promises');
const { Readable } = require('node:stream');
await pipeline(
Readable.from(rows), // async iterables also work
async function* (source) {
for await (const row of source) yield JSON.stringify(row) + '\n';
},
out,
);
Discussion