A lease migration endpoint is a queueing problem wearing a PDF costume. For a property-management SaaS moving signed contracts between formats, I would choose an asynchronous conversion endpoint with bounded workers, idempotency keys, and an audit record. Keep a small synchronous path for previews only. That split protects fidelity and tail latency when a portfolio import suddenly produces thousands of files.
Short answer: use asynchronous PDF conversion for production batches, measure p95 and p99 latency separately from conversion fidelity, and make every output replayable from an immutable source blob.
The field guide: match the endpoint to the workload
| Endpoint pattern | Pick this when | Main trade-off |
|---|---|---|
Synchronous POST /v1/pdf/convert
|
A user needs one preview under a tight request deadline | Simple call flow, but queue contention can turn a preview into a timeout |
Asynchronous POST /v1/pdf/convert plus status polling |
Lease imports, renewals, or any bursty batch | More state to operate, with much better isolation under load |
| Event-driven submission | A migration already has a durable message bus | Excellent retry control; harder local debugging and ordering guarantees |
The right boundary is a decision about failure ownership. A synchronous request makes the web tier own renderer time, memory spikes, and client retries. An async job makes a worker own those costs and lets the API acknowledge durable intent quickly.
For a preview, synchronous is fine when the input is small and the caller can tolerate a bounded timeout. For a 40,000-unit lease archive, it is not suitable: one slow template or a burst of embedded scans can consume every web worker. Put that work behind a queue and expose progress by job ID.
How should PDF endpoints balance migration fidelity, latency, and load?
Treat fidelity as a testable contract, not a feeling from opening one file. Define checks for page count, text extraction, font fallback, image dimensions, signatures, and metadata. Keep a golden corpus of representative leases: short renewals, multilingual clauses, tables, and scanned exhibits. A migration passes only when those checks pass at the required rate.
Latency needs its own scoreboard. Record queue wait, render time, blob upload time, and total time-to-available. Averages hide the incident that matters, so alert on p95 and p99 by input size and template family. I once read a healthy median as proof that a batch was healthy; the p99 was 11 minutes because five-page scans shared workers with tiny previews. The dashboard looked green until a leasing coordinator opened the slowest property and waited through several retries. We traced the delay to scan-heavy jobs consuming the same worker tokens as tiny previews, then replayed the exact input set in staging to confirm the diagnosis. The fix was a separate preview pool and a concurrency limit on scan-heavy jobs. Keep that replay harness: it turns the next latency spike into a bounded investigation instead of a guessing exercise.
Watch the tail.
Your diagram-in-words is: client submits source blob -> API writes an idempotent job row -> queue admits work under a token limit -> renderer emits a PDF -> validator checks the contract -> blob store writes the immutable result -> audit log records hashes and timestamps. If validation fails, keep the source and a classified failure reason; never silently substitute a lower-fidelity document.
The browser and worker should agree on bytes, not on an in-memory object. The standard Blob interface models immutable, file-like data and makes that handoff explicit. In TypeScript, a tiny adapter keeps the conversion service independent of a particular storage SDK:
type ConversionJob = {
id: string;
source: Blob;
templateVersion: string;
submittedAt: string;
};
async function submitLease(job: ConversionJob): Promise<void> {
const response = await fetch('https://api.example.test/v1/pdf/convert', {
method: 'POST',
headers: {
'content-type': 'application/json',
'idempotency-key': job.id
},
body: JSON.stringify({
templateVersion: job.templateVersion,
sourceSha256: await sha256(job.source),
submittedAt: job.submittedAt
})
});
if (!response.ok) throw new Error(`submit failed: ${response.status}`);
}
The route above is a generic contract. In production, persist the source reference and hash before acknowledging the request. A retry with the same idempotency key should return the original job identity. That one rule prevents duplicate lease packets when a client retries after a network timeout.
Operating the migration without losing the audit trail
Use a state machine: accepted, running, validated, published, or rejected. Store actor, template version, source hash, output hash, and UTC timestamps for every transition. Do not mutate a published artifact; write a new version and link it to the prior one.
Backpressure is visible when queue age rises before worker utilization reaches 100%. Export counters for accepted, rejected, retried, and validated jobs, plus histograms for each latency segment. Sample rendered PDFs in a quarantine bucket for human review, with access logs enabled.
Deploy a canary template version against the golden corpus, then release by property or region. US and EU tenants may require separate retention and residency policies; make that a configuration boundary, not a hidden branch in renderer code.
Limits and the point where another design wins
The catch is operational surface area. Async conversion needs a queue, durable status storage, cleanup policy, and an on-call playbook. It is a poor fit for a tiny product with occasional one-off exports; a synchronous library in the application process may be easier to reason about there.
High-fidelity rendering can also be the wrong goal for machine-only archives. If downstream systems only need searchable text, a text-first representation may cut latency and storage. Stick with a simpler path when legal review does not require visual equivalence, and document that decision.
I'm not sure one universal p99 target exists: your mileage will vary with page geometry, embedded fonts, and scan density. What is universal is the method: separate queue delay from render delay, test fidelity with a corpus, and preserve enough hashes to explain exactly which bytes were signed.
Top comments (0)