Operations / Correct under repetition
Backpressure when documents are requested faster than they render
What the system does when demand exceeds capacity, which is a decision to make rather than a behaviour to discover.
Why the default answer is wrong here
Every document pipeline has a rate above which it cannot keep up, and the behaviour at that point is decided by default unless somebody decides it deliberately. The default is usually to accept everything and queue it, which converts a capacity problem into an unbounded latency problem and eventually into memory exhaustion. The alternatives, shedding load, rejecting with a retry hint, or degrading to a simpler output, are all better than an unbounded queue, and all of them require having thought about it first.
The decisions
Reasons rather than a description of the code. Each has a default that is defensible in general and wrong for documents specifically.
- Bound every queue. An unbounded queue does not remove the limit, it hides it and moves the failure somewhere less recoverable.
- Decide what happens at the bound: reject, shed, or block the producer. All three are defensible and the default of growing forever is not.
- Reject with a retry hint rather than a bare error, so a well-behaved client can wait rather than hammer.
- Separate interactive traffic from bulk traffic, so a payroll run cannot starve a user waiting for a receipt. Two queues with different priorities is usually enough.
- Apply the limit at the edge rather than deep inside, so work is rejected before resources have been spent on it.
- Make the degraded mode explicit if you have one. Serving a cached or simplified document under load is a good answer when it is a decision and a bad one when it is a surprise.
In practice
A fragment, with the thing that goes wrong kept in a comment where it is the more instructive half.
// Two lanes, both bounded. Bulk cannot starve interactive.
const lanes = {
interactive: new Semaphore(8), // a user is waiting
bulk: new Semaphore(4), // a run, nobody waiting
};
async function render(spec, { lane = "interactive" } = {}) {
const permit = await lanes[lane].tryAcquire({ timeout: 250 });
if (!permit) {
if (lane === "bulk") {
// Nobody is waiting: push back on the producer instead of queueing.
throw new Backpressure("bulk lane saturated");
}
// Somebody is waiting: reject with a hint rather than a bare error.
const err = new TooBusy("try again shortly");
err.retryAfter = 5;
throw err;
}
try {
return await pdf.post("/v1/pdf", spec);
} finally {
permit.release();
}
}
/* The decision being made explicitly rather than by default:
bounded, both lanes an unbounded queue hides the limit
bulk pushes back the producer slows down
interactive rejects with a
Retry-After hint a client can wait politely
The default, accepting everything and queueing, turns a capacity
problem into an unbounded latency problem and then into memory
exhaustion. */What people do instead
An unbounded queue in front of the renderer. It looks like resilience and it is deferral: the queue grows, latency grows with it, and the eventual failure is memory exhaustion in the queueing process rather than a clean rejection at the edge.
What the symptom looks like
Queue depth growing monotonically is the signal that demand exceeds capacity, and it is visible long before anything fails. Latency rising with a flat throughput says the same thing from the other direction.
Frequently asked
Is this worth doing for a small volume of documents?
Some of it, and the cheap parts are the ones that matter. Allocating a document's identity before rendering it costs nothing and prevents duplicates forever. Logging the template version costs one field and answers most support questions. Queue design, backpressure and capacity planning are genuinely for scale and can wait until there is some.
Why is duplication treated as more serious than latency here?
Because a document usually carries an identity. A duplicated read is harmless and a duplicated invoice is a second numbered document for one event, which somebody has to reconcile by hand and which may already have been sent. That asymmetry is why the correctness half of this cluster is larger than it would be for most APIs.
How does this relate to the troubleshooting pages?
Those start from a symptom you are looking at right now and work back to a cause. These start from a decision made before the symptom exists. The two meet in the middle: a decision skipped here usually appears there some months later as a problem with no obvious explanation.
Related operational topics
The decisions that depend on each other, then the rest of the same group.
Designing a client that lives inside a rate limit
Building the caller so it stays within a limit rather than discovering it, which is a different question from what to do when a 429 arrives.
Queue design for a payroll run or any large document batch
Structuring a job that produces thousands of documents at once, where the interesting decisions are about failure and progress rather than throughput.
Choosing how many documents to render at once
Picking a concurrency that uses the available capacity without exceeding it, which is a measured number rather than a guessed one.
Idempotency for a document that must not be generated twice
Making a repeated request produce the same document rather than a second one, which matters more here than in most APIs because the artefact is often numbered.
Retrying a render safely, and which failures are safe to retry
Deciding which failures should be retried and which should not, because a blanket retry on a document endpoint is a duplication mechanism.
Every operational topic
The full list, grouped by correctness, visibility and cost.
What this API actually does
The options and endpoints these decisions are built on, one page each.
Most of these decisions are cheaper to make before the first production run than after the first incident, and none of them need a large system to be worth making.