Rate Limits and Quota Management in Document Generation APIs

A practical guide to handling rate limits, retries, and quota tracking when generating PDFs and documents at scale through third-party APIs.

rate-limitingapipdfbackendnode.jsretries

Rate Limits and Quota Management in Document Generation APIs

Document generation looks simple from the outside — send some data, get a PDF back — but the moment you put it in production, you hit the same wall every API consumer eventually hits: rate limits. A monthly report job that fires 10,000 invoice renders at midnight will trip almost any provider's per-second cap. This post covers how rate limits typically work in document generation APIs, how to design your client code around them, and how to manage quotas without surprising your users (or your bill).

How rate limits usually work

Most document APIs apply at least two layers of throttling:

  • Per-second or per-minute request rate — protects the rendering workers from bursts.
  • Monthly quota — tied to your plan (e.g., 50,000 documents/month).

Some providers add a third: concurrent renders, capping how many jobs can be in-flight at once. This matters because PDF generation is CPU-heavy; rendering 200 documents concurrently is very different from rendering 200 over a minute.

When you hit a limit, expect an HTTP 429 Too Many Requests, usually with headers like:

http
HTTP/1.1 429 Too Many Requests Retry-After: 2 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1730000000

Always read these. Hardcoding sleep durations is how you end up with thundering herds.

A safe baseline client

Here's a minimal fetch wrapper that respects Retry-After and backs off exponentially on other transient failures:

ts
async function requestWithRetry( url: string, init: RequestInit, maxAttempts = 5, ): Promise<Response> { for (let attempt = 1; attempt <= maxAttempts; attempt++) { const res = await fetch(url, init); if (res.status !== 429 && res.status < 500) { return res; } const retryAfter = Number(res.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : Math.min(2 ** attempt * 250, 10_000); const jitter = Math.random() * 250; await new Promise((r) => setTimeout(r, delayMs + jitter)); } throw new Error(`Request to ${url} failed after ${maxAttempts} attempts`); }

Two things to notice: we only retry on 429 and 5xx, and we always add jitter. Without jitter, multiple workers retrying in lockstep will collide on every attempt.

Controlling concurrency from the client

Even if the provider allows 100 requests/second, hammering them is wasteful. Use a concurrency limiter so your worker fleet behaves predictably.

ts
import pLimit from "p-limit"; const limit = pLimit(10); // max 10 in-flight renders const results = await Promise.all( invoices.map((inv) => limit(() => kamy.documents.render({ template: "invoice", data: inv, }), ), ), );

Pick a concurrency number based on your provider's documented concurrent-render limit, not your own machine's capacity. If you're on Kamy's standard plan with 20 concurrent renders, set the limiter to 15 or so to leave headroom for ad-hoc requests from your web tier.

Batch endpoints beat loops

If your provider exposes a batch endpoint, prefer it. One HTTP call rendering 100 documents avoids 100 rate-limit decrements and 100 TLS handshakes:

ts
const job = await kamy.documents.renderBatch({ template: "invoice", items: invoices.map((inv) => ({ data: inv })), }); // Poll or use webhooks for completion const result = await kamy.jobs.wait(job.id);

Batch jobs typically count against your quota per document rendered, but only against your rate limit as a single request. For nightly or end-of-month workloads, this is the single biggest optimization you can make.

Queue heavy workloads

For anything beyond a handful of documents, generation should happen in a background worker, not in a web request. A simple pattern:

  1. Web request enqueues a job to Redis/SQS/etc. and returns immediately.
  2. A worker pulls jobs, renders the document, stores the URL.
  3. The user is notified via webhook, email, or polling.

This decouples your user-facing latency from the document API's throughput and makes rate-limit handling a worker-only concern. If a worker gets throttled, it just pauses — the user never sees an error.

Tracking quota usage

Don't wait until you're at 100% to find out. Track usage yourself:

ts
async function recordRender(userId: string, count = 1) { await db.usage.upsert({ where: { userId_month: { userId, month: currentMonth() } }, update: { count: { increment: count } }, create: { userId, month: currentMonth(), count }, }); }

Combine this with the provider's usage endpoint (Kamy exposes GET /v1/usage) to reconcile. Alert when you cross 70% and 90% of your plan. Surprise overages are bad; surprise hard-stops in the middle of a billing run are worse.

Pass-through quotas for multi-tenant apps

If you're a SaaS reselling document generation, your customers need their own limits. Don't expose your provider's limits directly — wrap them:

ts
async function renderForTenant(tenantId: string, payload: RenderPayload) { const usage = await getMonthlyUsage(tenantId); const plan = await getTenantPlan(tenantId); if (usage.count >=