The request or the response
429 Too Many Requests when generating PDFs in bulk
A batch starts fine and starts failing partway through. Small runs succeed and large ones do not, which points at rate rather than at content.
What is actually happening
Unbounded concurrency. Mapping over a list with a promise per item starts every request at once, so a thousand payslips become a thousand simultaneous requests regardless of what any limit allows.
Confirming it is this and not something that looks like it
Count how many requests are in flight at the peak. If it equals the size of your input list, you have no concurrency control at all.
The fix
Bound concurrency, and retry the ones that are limited with exponential backoff and jitter. For a run that is genuinely large, use the batch endpoint rather than managing concurrency yourself.
async function mapWithLimit(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const i = cursor++;
results[i] = await withRetry(() => fn(items[i]));
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
async function withRetry(fn, attempts = 5) {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
if (err.status !== 429 || attempt >= attempts) throw err;
// Exponential, with jitter so a batch does not retry in lockstep.
const wait = 2 ** attempt * 250 + Math.random() * 250;
await new Promise((r) => setTimeout(r, wait));
}
}
}If that was not it
These produce the same symptom often enough to be worth ruling out before assuming the fix above did not work.
- Promise.all over the whole list, which is the unbounded case in its most common form
- Retries with no jitter, which resynchronise the burst you were trying to spread
- A Retry-After header, which tells you exactly how long to wait
- A queue worker whose concurrency was raised without anyone thinking about the downstream
Frequently asked
Does this happen with every rendering engine?
The behaviour behind it is not specific to one tool. Unbounded concurrency. Anything rendering HTML to a paged medium has to make the same decision, so the fix travels with you if you change how the render happens.
Will this show up as an error in my logs?
Yes, this one surfaces as a thrown error or a failed status, which is why it is at least findable. The harder half is that the message often names the call that was in flight rather than the thing that actually failed.
Related failures
Problems people arrive at from the same starting point, or mistake for this one.
The downloaded PDF is corrupted and will not open
The bytes were treated as text somewhere.
The PDF opens in the browser instead of downloading
Content-Disposition is absent or set to inline.
413 Payload Too Large when sending HTML to render
The request body exceeded a limit.
401 Unauthorized from the render API
The key that arrived is not the key you think you sent.
The PDF is truncated or ends mid-document
The response was cut off.
CSS for paged documents, and what actually works
Which parts of the paged media specification a browser-based render implements.
Everything that goes wrong, by category
The full list, grouped by where in the pipeline it breaks.
Paste your markup and see the rendered document, without signing up.