Rate Limits and Quota Management in Document Generation APIs
Document generation looks cheap until you ship it to production. A single user action can spawn ten PDF renders (invoice, receipt, shipping label, packing slip, compliance report...), and a bulk export can queue thousands. Any API that renders documents has to protect itself with rate limits and quotas — and any client that consumes such an API has to plan for them. This post covers both sides: how these limits typically work, and how to build clients that don't fall over when they hit them.
Why document APIs rate-limit aggressively
Rendering a PDF is not like serving JSON. A single request may spin up a headless browser, load fonts, fetch remote images, run a template engine, and stream the result to storage. CPU and memory usage per request can be 100–1000x that of a typical REST call.
Because of that, document APIs usually enforce multiple layers of limits:
- Request rate — requests per second or minute per API key.
- Concurrency — how many renders can be in-flight at once.
- Monthly quota — total documents per billing cycle.
- Payload size — max template or data payload.
Kamy, for example, exposes all four as separate headers so you can react to whichever one you're hitting.
Reading rate-limit headers
Most well-designed APIs return standard-ish headers on every response. Here's what Kamy sends:
httpHTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 87 X-RateLimit-Reset: 1719412800 X-Concurrency-Limit: 10 X-Concurrency-Current: 3 X-Quota-Remaining: 48213
When you exceed a limit, you get a 429 Too Many Requests with a Retry-After header:
httpHTTP/1.1 429 Too Many Requests Retry-After: 12 Content-Type: application/json { "error": "rate_limit_exceeded", "scope": "requests_per_minute", "retry_after_seconds": 12 }
The scope field matters. Retrying immediately against a concurrency limit is fine once a slot frees up; retrying against a monthly_quota limit will never succeed until the next billing cycle. Treat them differently.
A minimal client with retry and backoff
Never blindly retry a 429. Use the Retry-After value, add jitter, and cap total attempts. Here's a small wrapper:
tsimport { setTimeout as sleep } from "node:timers/promises"; async function renderWithRetry(payload: unknown, maxAttempts = 5) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { const res = await fetch("https://api.kamy.dev/v1/documents", { method: "POST", headers: { "Authorization": `Bearer ${process.env.KAMY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), }); if (res.status !== 429) return res; const body = await res.json(); if (body.scope === "monthly_quota") { throw new Error("Quota exhausted — will not retry"); } const retryAfter = Number(res.headers.get("Retry-After") ?? 1); const jitter = Math.random() * 500; await sleep(retryAfter * 1000 + jitter); } throw new Error("Exceeded retry attempts"); }
Two things to note: we bail early on quota errors (retrying is useless), and we add jitter so a fleet of workers doesn't stampede at the exact same moment.
Controlling concurrency on your side
If the API allows 10 concurrent renders and you fire off 500 at once, you'll get a wall of 429s and waste time on retries. Bound concurrency before it hits the network.
tsimport pLimit from "p-limit"; const limit = pLimit(8); // leave headroom below the 10-concurrent cap async function renderBatch(items: Item[]) { return Promise.all( items.map((item) => limit(() => renderWithRetry({ template: "invoice", data: item })) ) ); }
Setting the concurrency slightly below the server-side limit is deliberate — it gives you room for retries and for any other worker sharing the same API key.
Use a queue for bulk jobs
For anything larger than a few hundred documents, do not run renders inline in a web request. Push jobs onto a queue (BullMQ, SQS, Cloud Tasks) and drain them with a worker pool sized to your concurrency budget.
tsimport { Worker } from "bullmq"; new Worker( "pdf-render", async (job) => { return renderWithRetry(job.data); }, { concurrency: 8 } );
This gives you:
- Predictable load on the API.
- Automatic retries with exponential backoff (handled by the queue).
- Visibility into failures and throughput.
- The ability to pause the worker if you're approaching your monthly quota.
Monitoring quota usage
Don't wait until a customer's end-of-month invoice generation fails to notice you're at 99% of quota. Emit metrics from your rate-limit headers on every response:
tsfunction recordUsage(res: Response) { const remaining = Number(res.headers.get("X-Quota-Remaining")); const rateRemaining = Number(res.headers.get("X-RateLimit-Remaining")); metrics.gauge("kamy.quota.remaining", remaining); metrics.gauge("kamy.rate.remaining", rateRemaining); }
Alert when quota.remaining