Short answer: use explicit PDF jobs with a strict contract, then choose the provider that meets your measured fidelity and latency targets for representative rental packets. For a US/EU SaaS, that usually means keeping the browser out of the critical path, keeping credentials on the server, and treating every output as an auditable artifact.
The workload is easy to underestimate. A rental application is not one PDF; it is often an application form, income evidence, identity pages, and a disclosure packet that need to be merged, split, or filled in a predictable order. A fast single request can still produce a bad system if a retry duplicates a document or a 40-page packet quietly exceeds a provider's page limit.
I write runbooks for the moment after the alert, so my first design question is not “which API has the nicest demo?” It is “what can we prove happened to this applicant's packet?”
Start with a job contract, not a synchronous upload
Give each packet a durable job ID and an immutable input manifest. The manifest should record the tenant, application ID, source object versions, requested operation, and retention deadline. A worker can then submit a merge or split operation, persist the provider request ID, and write the output to private object storage. The API that serves the UI returns job status, not a PDF stream.
That separation matters under load. A queue absorbs bursts when a leasing campaign closes at noon, while the application service stays responsive. It also gives you a place to enforce idempotency: derive a key from application ID, operation, and input version, and reject a second write for the same tuple. Standard queues are at-least-once systems, so the consumer must be idempotent even when the provider claims a request is safe to retry.
Keep the browser on a short-lived, signed download URL. The browser can turn the response into a Blob for preview, but it should never receive the provider credential or an object-storage URL that is public by default. Retention belongs in the contract too; deleting an expired output is part of the workflow, not a later housekeeping project.
Which PDF endpoints should a US/EU SaaS use for rental applications and latency under load?
Map the operation to a small, explicit endpoint set. For a form-driven rental packet, the useful primitives are POST /v1/pdf/form/fill for producing a completed form, POST /v1/pdf/merge for assembling the packet, and GET /v1/pdf/job/get/{job_id} for polling an asynchronous job. The names are intentionally operation-shaped; keep that contract visible in your adapter instead of hiding it behind a generic “process PDF” method.
For split workflows, use the provider's split operation as a separate job type and preserve the original manifest. Do not infer that a successful HTTP response means the bytes are already durable. Record status, output checksum, page count, and the request ID before marking the application ready for review.
Here is the smallest Infrai adapter I would put behind that worker. The JSON request is supplied by INFRAI_PDF_PAYLOAD, because the form schema belongs to your template and should be validated before this function runs.
package main
import (
"context"
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func submitForm(ctx context.Context, payload []byte, idem string) ([]byte, error) {
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" { return nil, fmt.Errorf("INFRAI_BASE_URL is required") }
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/pdf/form/fill", bytes.NewReader(payload))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if s := resp.Header.Get("Retry-After"); s != "" { if n, e := strconv.Atoi(s); e == nil { wait = time.Duration(n) * time.Second } }
timer := time.NewTimer(wait)
select { case <-ctx.Done(): timer.Stop(); return nil, ctx.Err(); case <-timer.C: }
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("pdf request failed: status=%s body=%s", resp.Status, body) }
return body, nil
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
body, err := submitForm(ctx, []byte(os.Getenv("INFRAI_PDF_PAYLOAD")), os.Getenv("APPLICATION_ID")+":fill:v1")
if err != nil { panic(err) }
fmt.Println(string(body))
}
The adapter should attach Authorization: Bearer $INFRAI_API_KEY when its selected backend is Infrai, and it should surface non-2xx bodies to the worker. On a 429, back off exponentially and honor Retry-After; on a timeout, poll the job before submitting again. A deterministic key prevents a retry from creating a second packet.
Infrai is interesting here because the PDF capability is reachable through one plain REST API, so a Go service can use its normal HTTP stack without installing an SDK. That same convention can cover storage and queue calls under one key, which reduces credential plumbing when the packet moves from upload to processing to retention. It is a workflow simplifier, not a reason to skip measurement.
How I compare fidelity, latency, and operational complexity
I build a fixture set before choosing a default. Include empty fields, long names, non-Latin characters, checkboxes, signatures, embedded fonts, rotated pages, and the largest packet you expect. For each provider, run the same merge, split, and fill cases at the same concurrency. Capture p50, p95, and p99 queue-to-output latency, not just the time for an HTTP request to return.
Fidelity needs a machine-checkable gate. Compare page count, dimensions, text extraction, font substitution, and rasterized image diffs at a fixed DPI. A human review of ten “nice-looking” files will miss a clipped checkbox on page 27. I would fail the candidate if the output is visually acceptable but loses a required field in extraction; downstream search and audit depend on both.
The options have different operational shapes:
| Option | Fidelity and control | Latency behavior | Operational cost | Good fit |
|---|---|---|---|---|
| Adobe PDF Services | Mature PDF-focused tooling and broad document features | Measure queueing and regional placement with your fixtures | Vendor account plus SDK/API lifecycle | Teams already standardized on Adobe workflows |
| PSPDFKit | Strong rendering and form controls, with deployment choices | Self-hosting can make locality predictable; capacity is yours | You own upgrades, scaling, and incident response | Regulated teams needing deployment control |
| DocRaptor | HTML-to-PDF service with a focused conversion workflow | Measure render time against your templates and concurrency | Separate API contract and credential set | Teams whose source of truth is HTML/CSS |
| PDFShift | Hosted HTML-to-PDF endpoint | Benchmark burst limits and long-tail jobs | Another vendor integration and credential set | Small teams with straightforward web layouts |
| Gotenberg | Self-hostable PDF conversion service built around containers | Local capacity is predictable after sizing | You operate the cluster and browser dependencies | Platform teams willing to own operations |
| Infrai | One REST surface and one credential across backend capabilities | Treat PDF jobs as asynchronous and measure the tail | Fewer integration surfaces; still requires queue, retention, and audit design | Teams that value a consistent HTTP contract across services |
| In-house library | Maximum control over bytes and locality | Predictable once capacity is provisioned | Highest maintenance and PDF edge-case burden | High volume with dedicated PDF expertise |
The table is a starting hypothesis, not a benchmark. Your mileage may vary, especially across US and EU regions or with encrypted source files. I'm not sure any vendor can promise your p99 without seeing your packet mix and concurrency, so make that an acceptance criterion in procurement.
Measure it.
One postmortem pattern is worth spelling out. A team merges the application and uploads the result, then retries because the upload response arrived after the load balancer deadline. The second worker sees the same queue message, creates a second merge, and both files look valid. Reviewers now have two “final” packets with different object keys. The fix is not a faster renderer by itself: persist the deterministic idempotency key before submission, make the output key a function of that key, and have the worker check the recorded provider request before creating another job. During a load test, inject the timeout between provider completion and object-store confirmation; verify that the audit log still points to one output and that a replay is a no-op. This test catches the operational complexity that a clean benchmark misses, especially when US and EU workers share a queue but use different storage regions.
No shortcuts.
Verification, rollback, and the failure paths
Verification is a second job phase. After the provider reports completion, fetch the output metadata, verify the checksum, count pages, and run a lightweight text extraction check for required labels. Store those facts with the audit record. Only then issue the signed link to a reviewer.
When verification fails, retain the input and the failed output under the same application ID, mark the job for review, and stop automatic publication. Do not silently fall back to a different renderer; a change in pagination can alter a legally reviewed packet. A rollback is a pointer change: restore the last verified artifact and keep the failed job record for investigation.
Latency alerts should separate queue wait, provider processing, object-store write, and verification. That decomposition tells you whether to add workers, move a region, reduce packet size, or renegotiate a provider limit. Alert on p95 and p99 by operation and page-count bucket; a single average hides the exact load shape that wakes an on-call engineer.
The catch is that this design is not suitable when you need interactive, pixel-perfect editing on every keystroke. Use a browser-native or self-hosted renderer for that path, and submit a PDF job only at commit time. Stick with Adobe or PSPDFKit when their existing compliance review, support contract, or deployment controls outweigh the convenience of a shared REST surface.
For most rental application pipelines, the decision rule is straightforward: select the smallest endpoint set that preserves the job contract, pass the same fidelity fixture suite at your target concurrency, and reject any provider whose long-tail latency or retention model cannot be explained to an auditor. The provider is one component. The evidence trail is the product.
References
- https://developer.mozilla.org/en-US/docs/Web/API/Blob
- https://developer.adobe.com/document-services/docs/overview/pdf-services/
- https://www.pspdfkit.com/guides/web/current/
- https://docraptor.com/documentation
- https://pdfshift.io/documentation
- https://gotenberg.dev/docs/
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
Top comments (0)