Short answer: use a hosted PDF API when a small team needs repeatable form filling across bursty workers and can accept a measured network hop; keep a local PDF library when residency, offline operation, or a hard tail-latency budget makes that hop unacceptable. Decide with a load test on real evidence, not a quick demo.
I run a one-person SaaS, so every infrastructure choice is a revenue-per-hour choice. Our concrete job is boring but consequential: fill and flatten compliance forms in batches, then retain an immutable-looking artifact and the evidence that produced it. I want to ship weekly and outsource the undifferentiated PDF plumbing. I also do not want a queue that quietly eats a customer's export window.
That constraint changed the question. It is not “which renderer has the nicest API?” It is “which boundary keeps 500 forms moving while preserving an audit trail?”
What makes a filled PDF compliance evidence?
Evidence is a chain, not just a file. For each output I record the input digest, template revision, creation time in UTC, renderer mode, and object-storage key beside the PDF. The original template stays available too. An auditor may ask how a database value became a page value, and a flattened file alone cannot answer that.
Flattening is useful because the exported fields are no longer ordinary editable form controls. It does not make a file magical or prove that the input was correct. The application still validates required fields, stores an event for the requester, and links the resulting hash to the job record.
I use an idempotency key derived from the job identifier and template revision. A retry must address the same logical job; generating a new key for every attempt can create two evidence files for one request. That duplicate is an operational failure even when both PDFs render perfectly.
Keep the stages visible: fetch, bind, flatten, validate, upload, and record. When a customer asks why a batch is late, those timestamps tell me whether the problem is queue pressure, rendering, or storage. Without them, a single “PDF duration” metric is mostly theater.
Should hosted PDF APIs replace local libraries when latency is under load?
Not by default. A hosted API gives every worker one HTTP contract and moves native renderer patching outside my deploy. A local library removes the network leg and can keep source bytes inside a private subnet. Both can be correct; their failure modes are different.
For a 500-form batch, I budget queue wait separately from render time. Suppose the median render is 120 ms and the p99 is 2 seconds. Ten workers can look healthy while the slow tail blocks an export deadline, especially when a burst and a retry storm arrive together. I measure p50, p95, and p99 at fixed concurrency, then repeat the run with the same form mix customers submit: long names, missing optional values, checked boxes, rotated pages, embedded fonts, and malformed templates.
Tail latency is the product experience.
Measure the tail.
I raise concurrency in small steps. A renderer that behaves at 2 workers can change shape at 16 when memory pressure and font loading compete. The useful result is a curve, not a single fastest sample. Your mileage may vary with page count and font files; publish that workload shape with the number so another engineer can reproduce it.
| Pressure | Hosted boundary tends to fit | Local boundary tends to fit |
|---|---|---|
| Burst traffic | Queue work, cap outbound concurrency, and watch queue age | Add isolated workers and watch CPU and memory contention |
| Data residency | Use only when region, retention, and deletion terms match policy | Keep bytes in the controlled network |
| Reproducibility | Pin template and renderer versions in each request | Pin the library and native dependencies in the image |
| Tail latency | Set deadlines and bounded retries around the HTTP call | Remove network variance, then test native contention |
| Small-team operations | Outsource renderer maintenance | Accept patching, regression tests, and on-call ownership |
The catch is policy. A hosted service is not suitable when source records may not leave your boundary, when an offline export is mandatory, or when the legal deadline is tighter than a remote call can reliably meet. Stick with a local library in those cases. A local renderer is a poor fit when the team cannot patch native dependencies or maintain a corpus of difficult forms; a failed upgrade can then consume more time than the network hop ever did.
A small TypeScript worker with a replaceable renderer
The worker owns deadlines, hashing, and result validation. The renderer is an interface, so a local implementation and an HTTP implementation can be tested against the same contract. That keeps the choice reversible while the batch path is still young.
type EvidenceJob = {
jobId: string;
templateVersion: string;
fields: Record<string, string | number | boolean>;
inputSha256: string;
};
type PdfRenderer = {
fillAndFlatten(job: EvidenceJob, signal: AbortSignal): Promise<Uint8Array>;
};
async function renderEvidence(
renderer: PdfRenderer,
job: EvidenceJob,
timeoutMs = 5000,
): Promise<{ bytes: Uint8Array; sha256: string }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const bytes = await renderer.fillAndFlatten(job, controller.signal);
if (bytes.byteLength === 0) throw new Error('empty PDF result');
return { bytes, sha256: await hashBytes(bytes) };
} finally {
clearTimeout(timer);
}
}
async function hashBytes(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Buffer.from(digest).toString('hex');
}
The queue, not the renderer, decides when to retry. I retry a timed-out transport call with exponential backoff and jitter, but I do not retry a rejected template or a validation failure. Those jobs go to a dead-letter queue with the input digest and the reason attached. A deadline prevents one saturated dependency from consuming every worker slot.
The returned bytes are an immutable Uint8Array at this boundary. In a browser or edge worker, they can be wrapped in a Blob; the MDN Blob API documents that byte container and its streaming methods. On a server, writing the bytes directly to object storage avoids an unnecessary base64 expansion.
How do I test production scale before choosing a PDF boundary?
Start with a versioned corpus, not a blank form. Include the ugly cases. Capture render duration, queue wait, output size, retry count, validation status, and hash. A renderer upgrade must be compared with the previous output using the same corpus and template revisions.
I run three passes. A steady pass holds the expected arrival rate for 30 minutes. A burst pass sends several minutes of work in a few seconds. A soak pass runs long enough to expose memory growth. For each pass, I vary concurrency, record p50/p95/p99, and check the percentage that misses the export deadline. Average latency hides the exact tail that customers feel. In one realistic run, the queue starts with 40 jobs, rises to 500 during a partner import, and drains only after the renderer catches up; I watch the age of the oldest job every minute, correlate it with outbound request counts, and keep the corpus fixed so a change in page mix cannot masquerade as a capacity win. When p95 looks fine but p99 climbs with each burst, I lower concurrency before adding retries, because retries would multiply the same pressure and make the deadline miss harder to diagnose.
I set a release gate of no more than 1% of jobs exceeding that deadline, with one audit row and one object hash for every completed job. Those are local policy choices, not universal compliance standards. If a customer's retention rule or SLA changes, the gate changes with it.
Observability joins the queue span to the renderer span and the storage write. Trace context can cross an HTTP boundary, while a local call still gets a span so the two deployment modes remain comparable. Alerts cover p95, p99, queue age, dead-letter count, and hash mismatch. A green CPU graph is not proof that evidence is arriving on time.
The decision rule for a one-person SaaS
Choose the hosted boundary when consistent rendering across many workers is the hard part, the service's residency and retention contract passes review, and the queue can absorb network variance. Choose the local library when bytes cannot leave your network, offline operation is mandatory, or the tail budget is tighter than a remote call can reliably satisfy.
I would keep a local validation step either way and make rendering replaceable. That costs a small interface and a pair of integration tests. In return, I can run a local implementation in development, a hosted implementation for bursts, or both behind a feature flag without rewriting the evidence ledger.
Price is a secondary input. Count engineering hours, incident response, storage, and the cost of a missed export window. The lowest per-call figure can lose if it increases queue age or forces a small team to own native dependencies it cannot patch quickly.
The finish line is deliberately boring: deterministic inputs, bounded retries, measured tail latency, and an audit record for every flattened PDF. Once those are in place, hosted versus local is a deployment decision I can revisit instead of a compliance gamble.
Top comments (0)