Add Load-Shedding Middleware
Reject new work with 503 and Retry-After when in-flight requests exceed a measured limit, keeping accepted requests within their latency target.
Task
Prevent an overload spike from collapsing latency for every request by bounding concurrency and failing fast at the admission boundary.
When to use this
A traffic spike or a slow dependency increases in-flight requests until queues and memory grow. Every caller slows down, and retries make the load worse.
Prerequisites
- An HTTP service with a measured concurrency limit
- A load test that identifies the limit at the latency target
- A reverse proxy that preserves the 503 response and Retry-After header
Steps
1. Add an admission counter that rejects when the in-flight count reaches the limit.
export function loadShedding(maxInFlight: number, retryAfterSeconds = 5) {
let inFlight = 0;
return (req, res, next) => {
if (inFlight >= maxInFlight) {
res.setHeader('Retry-After', String(retryAfterSeconds));
return res.status(503).json({ error: 'overloaded' });
}
inFlight += 1;
res.on('finish', () => { inFlight -= 1; });
res.on('close', () => { inFlight -= 1; });
next();
};
}
Expected output: The request over the limit receives 503 immediately, and accepted requests decrement the counter on finish or close.
2. Mount the middleware before body parsing and expensive work, and exclude health probes.
app.use('/healthz', healthHandler);
app.use(loadShedding(Number(process.env.MAX_IN_FLIGHT ?? 100)));
app.use(express.json());
Expected output: Overload is rejected before JSON parsing or database work.
3. Load test beyond the limit and verify accepted-request p95 and rejection speed.
hey -z 30s -c 200 http://localhost:3000/api/itemsExpected output: Accepted requests stay near the target latency and rejected requests return quickly with 503.
Troubleshooting
Problem
In-flight count goes negative
Solution
Both finish and close can fire for one request. Guard the decrement with a per-request flag or use a single termination event.
Problem
Requests are shed before the service is actually saturated
Solution
The limit is too low or the measurement excluded queueing. Derive it from a load test at the SLO, not from CPU alone.
Limitations
- The counter is per process; a global limit needs a gateway or shared state.
- Shedding is a product decision: some requests fail by design to protect the rest.
Bound what you accept
A queue that accepts everything under overload serves everyone slowly. Load shedding bounds in-flight work and rejects the excess at the admission boundary. The accepted requests keep their latency; the rejected ones receive a fast, explicit signal.
if (inFlight >= maxInFlight) {
res.setHeader('Retry-After', '5');
return res.status(503).json({ error: 'overloaded' });
}
inFlight += 1;
res.on('finish', () => { inFlight -= 1; });
Measure the limit
Run a load test and find the concurrency at which p95 starts to exceed the target. That is the starting limit, not the number of CPUs. Include queueing and dependency latency. Revisit after changing the query path, instance size, or downstream capacity.
Reject correctly
Use 503 with Retry-After for server overload; use 429 for a client quota. Log the shed count and reason, but do not log every rejected request at error level. Exclude health checks so the orchestrator can still observe the process.
Verify the limit
Expected outcome: under a load test above the limit, accepted requests stay near the latency target and excess requests receive 503 quickly with a positive Retry-After. Confirm that the in-flight counter returns to zero after the test, including when clients disconnect mid-request. If the counter drifts upward, the middleware is registering more than one decrement or missing a termination event.