·5 min read

Generating PDFs at scale: batching, concurrency, and idempotency

At scale, the failure mode isn't the PDF API being slow — it's callers double-billing themselves on retries, or overwhelming the renderer with unbounded concurrency. The fix is boring and specific: cap concurrent in-flight requests (start around 5-10 per worker), always send an idempotencyKey derived from your own job ID so retries are free, and treat every render as one of two outcomes (ok or failed) that your code handles explicitly rather than assuming success.

The three things that actually break at volume

Generating one PDF is easy. Generating 50,000 a day exposes three problems that never show up in a demo: unbounded concurrency saturating either your process or the render backend, retries that silently double-bill because there's no dedupe key, and partial failures that get treated as successes because nobody checked the response shape. None of these are exotic — they're the same problems you'd hit fanning out any external API call — but PDF rendering is heavier per-call (a real Chromium instance, not a JSON round-trip) so the failure modes show up sooner and cost more when ignored.

Concurrency: cap it, don't guess it

Docweave renders each request in an isolated Chromium instance via Docweave's HTML-to-PDF API, the same rendering path whether the source is raw html, a url, or a stored template. That isolation is good for correctness (no state leaking between documents) but it means every concurrent request has real memory and CPU cost on the backend. If you fire 500 requests at once from a batch job, you'll see elevated latency and, past a point, 429s — that's the API protecting itself, not a bug.

The practical fix is a concurrency-limited queue on the caller's side, not a bigger retry loop. In Node:

import pLimit from 'p-limit';

const limit = pLimit(8); // 8 in-flight renders at a time

async function renderOne(job) {
  const res = await fetch('https://docweave.dev/api/v1/pdf', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.DOCWEAVE_API_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/pdf',
    },
    body: JSON.stringify({
      source: { type: 'html', html: job.html },
      idempotencyKey: `invoice-${job.invoiceId}`,
    }),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => null);
    throw new Error(`render failed for ${job.invoiceId}: ${err?.error ?? res.status}`);
  }

  const buf = Buffer.from(await res.arrayBuffer());
  await fs.writeFile(`./out/${job.invoiceId}.pdf`, buf);
}

await Promise.all(jobs.map((job) => limit(() => renderOne(job))));

Start at 5-10 concurrent requests per worker process and watch p95 latency as you raise it. If you're running multiple workers (e.g. serverless functions fanning out independently), the effective concurrency is the sum across all of them — coordinate through a shared queue (SQS, a Postgres-backed job table, whatever you already have) rather than each worker guessing in isolation.

Idempotency: the retry problem is a billing problem

Per-document pricing (see pricing) means every successful render is a billing event. That's simple until you add retries: a request that times out on the client side might have actually succeeded on the server, and a naive retry loop will generate — and pay for — the same document twice. idempotencyKey exists to close that gap. Send the same key for a logical retry of the same job, and a repeat request within the dedupe window returns the original result instead of re-rendering.

The key should be derived from something stable in your own system, not a random UUID generated per attempt (that defeats the purpose):

// Good: stable across retries of the same job
const idempotencyKey = `invoice-${invoiceId}-v${templateVersion}`;

// Bad: a new key every attempt means every retry is a new billable render
const idempotencyKey = crypto.randomUUID();

Include a version or content-hash suffix if the same logical document can legitimately need re-rendering (e.g. the invoice template changed). That way a genuine re-render after a fix isn't blocked by a stale cached result, but an accidental duplicate retry is.

Handling failures explicitly

A render can fail for reasons that have nothing to do with your account: a source URL that times out, a template with a JSON schema mismatch, a page that's too large to rasterize in the time budget. Docweave's orchestration never lets a render failure escape as an unhandled exception — the API returns a normal HTTP response (502 on render failure) with a JSON error body describing what happened, so your batch job can log it and move on instead of crashing the whole run.

const res = await fetch('https://docweave.dev/api/v1/pdf', { /* ... */ });

if (res.status === 502) {
  const { error } = await res.json();
  await deadLetterQueue.push({ jobId: job.invoiceId, error });
  return; // don't retry a deterministic failure (e.g. bad template data) forever
}

if (!res.ok) {
  throw new Error(`unexpected status ${res.status}`); // auth, rate limit, etc — retry with backoff
}

Separate retryable failures (429 rate limits, transient 5xx) from non-retryable ones (a template that will never render successfully with the data you gave it). Retrying the latter just burns time and, without idempotency keys, money.

Why per-document pricing changes the math

Some PDF APIs price per page, which means a 40-page report costs more than a 1-page receipt even though both are one render call and roughly the same server work per call. At batch volume that adds friction: you have to estimate page counts in advance to budget, and a template change that adds a page silently changes your bill. Per-document pricing means the cost calculator gives you an accurate number from render volume alone, which is what you actually control in a batch job — you know how many invoices, receipts, or reports you're generating; you don't always know in advance how long each one will render to.

This matters most for the document types that get batched hardest: invoices, receipts, and recurring reports — all cases where volume is predictable (one per order, one per billing cycle) but page count varies with content. If you're evaluating providers, our comparison to PDFShift covers pricing model differences in more detail.

Batching from an agent or automation pipeline

If the batch job itself is an AI agent — generating a folder of contracts or certificates from templated data — the same concurrency and idempotency rules apply, just called through MCP instead of REST. See Docweave's PDF API for AI agents for the broader pattern; for the batching case specifically, loop generate_pdf calls through your agent framework's own concurrency control rather than assuming the MCP server queues for you:

for (const job of jobs) {
  await limit(() =>
    generate_pdf({
      source: { type: 'template', templateId: 'contract-v2', data: job.data },
      outputPath: `./out/contract-${job.id}.pdf`,
      idempotencyKey: `contract-${job.id}`,
    })
  );
}

Full framework-specific batching examples (queues, retry helpers, worker pools) are in the docs; the concurrency and idempotency rules above are language-agnostic and apply whether you're calling from Node, Python, or Go.

FAQ

What concurrency limit should I use for batch PDF generation?

Start at 5-10 concurrent requests per worker process and increase gradually while watching p95 latency and error rate. If you run multiple workers, coordinate through a shared queue so total concurrency across all workers stays bounded — each worker guessing independently is how you accidentally hit rate limits.

Does idempotencyKey prevent all duplicate charges?

It prevents duplicate charges for retries of the same logical job within the dedupe window, as long as you derive the key from something stable (like your own job ID) rather than generating a new key per attempt. It doesn't dedupe two genuinely different requests that happen to produce similar output.

What happens if a render fails mid-batch — does it stop the whole job?

No. Render failures return a normal HTTP response (502) with a JSON error body rather than throwing an unhandled exception, so a well-written batch loop can log the failure, push it to a dead-letter queue, and continue with the rest of the batch.

Is per-document pricing actually cheaper at scale than per-page pricing?

It depends on your average page count — per-document pricing is more predictable for planning (you know your volume in advance) but per-page could be cheaper if your documents are consistently very short. Run your actual volume and typical page count through the cost calculator rather than assuming either model wins.

Related

Generate your first PDF in minutes.

Get an API key