Rate Limits and Quota Management in Document Generation APIs
Document generation is deceptively expensive. A single PDF render can spin up a headless browser, load fonts, fetch remote images, and serialize hundreds of pages. Multiply that by a burst of "download all invoices" clicks from your users, and you'll hit an API's rate limit faster than you'd expect. This post walks through how to reason about rate limits in document generation APIs, how to handle them cleanly in code, and how to manage your quota so you don't wake up to a 429 storm in production.
Why document APIs rate-limit aggressively
Most REST APIs rate-limit to protect their infrastructure. Document generation APIs have an even stronger reason: each request consumes real compute — CPU, memory, and often a headless Chromium instance. A typical text-only endpoint might handle thousands of requests per second per instance. A PDF renderer might handle five.
That's why providers usually enforce two dimensions:
- Rate limits — requests per second/minute (burst protection).
- Quotas — total documents per billing period (usage caps).
Understanding both is the difference between a system that scales and one that silently fails during your Monday morning batch job.
Read the response headers
Any well-designed API tells you exactly where you stand. Kamy, for example, returns these on every response:
httpHTTP/1.1 200 OK X-RateLimit-Limit: 60 X-RateLimit-Remaining: 42 X-RateLimit-Reset: 1712345678 X-Quota-Limit: 10000 X-Quota-Remaining: 8432
Don't wait for a 429 to react. Log these headers. Emit them as metrics. If X-RateLimit-Remaining drops below a threshold during normal operation, that's a signal to either slow down or upgrade your plan before things break.
Here's a minimal middleware to capture this in Node.js:
tsimport fetch from "node-fetch"; import { metrics } from "./metrics"; export async function trackedFetch(url: string, init?: RequestInit) { const res = await fetch(url, init); const remaining = Number(res.headers.get("x-ratelimit-remaining")); const quotaLeft = Number(res.headers.get("x-quota-remaining")); if (!Number.isNaN(remaining)) { metrics.gauge("api.ratelimit.remaining", remaining); } if (!Number.isNaN(quotaLeft)) { metrics.gauge("api.quota.remaining", quotaLeft); } return res; }
Handling 429 with exponential backoff
When you do get throttled, respect the Retry-After header if it's present, and fall back to exponential backoff with jitter otherwise. Retrying immediately just makes things worse.
tsasync function renderWithRetry(payload: unknown, attempt = 0): Promise<Response> { const res = await trackedFetch("https://api.kamy.dev/v1/documents", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.KAMY_API_KEY}`, }, body: JSON.stringify(payload), }); if (res.status !== 429) return res; if (attempt >= 5) throw new Error("Rate limit: max retries exceeded"); const retryAfter = Number(res.headers.get("retry-after")); const backoffMs = !Number.isNaN(retryAfter) ? retryAfter * 1000 : Math.min(1000 * 2 ** attempt, 30_000) + Math.random() * 500; await new Promise((r) => setTimeout(r, backoffMs)); return renderWithRetry(payload, attempt + 1); }
The jitter matters. If 200 workers all retry at exactly the same interval, you've just built a synchronized flood.
Client-side rate limiting
Retrying is reactive. The better pattern is to shape your outbound traffic so you never hit the limit in the first place. A simple token bucket or a concurrency limiter works well.
tsimport pLimit from "p-limit"; // Cap concurrent requests below the API's per-second limit const limit = pLimit(10); async function renderInvoices(invoices: Invoice[]) { return Promise.all( invoices.map((invoice) => limit(() => renderWithRetry({ template: "invoice", data: invoice })) ) ); }
For batch jobs, queue-based workers with a controlled concurrency count are almost always the right architecture. BullMQ, SQS with Lambda reserved concurrency, or Temporal all work well here.
Batch endpoints beat loops
If your provider offers a batch endpoint, use it. One request that renders 50 documents is dramatically cheaper — for both you and the provider — than 50 individual requests. It also counts as a single rate-limit hit.
tsconst res = await trackedFetch("https://api.kamy.dev/v1/documents/batch", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.KAMY_API_KEY}`, }, body: JSON.stringify({ template: "invoice", items: invoices.map((i) => ({ data: i })), }), });
Track quota, not just rate
Rate limits reset every minute. Quotas don't. A silent quota drift is the kind of problem that surfaces at 3 AM on the last day of the month.
Set up an alert well before you hit the ceiling. A simple daily job that queries usage and posts to Slack works fine:
tsconst usage =