Short answer: choose an explicit PDF job contract for multi-source board books, validate every input before merging, and measure fidelity and latency under realistic load before committing to a provider. For a US/EU SaaS, keep credentials on the server, return short-lived object links, and make the final artifact auditable. The endpoint is only one part of the reliability decision.
The incident lesson: a merge is a job, not a button
The production failure mode I worry about is mundane: one board packet contains exports from billing, support, and marketplace operations, and one source arrives late or has a malformed page. A synchronous request then times out while a retry starts a second merge. The dashboard shows two PDFs, and nobody can prove which one was sent to the board.
That is why I treat PDF assembly as a bounded job. The request records a source manifest, a caller-supplied idempotency key, the intended retention window, and the policy version used for redaction. Validation happens before expensive work. A worker performs the merge, writes an immutable result, and a separate read checks job state. This contract survives a provider change because the application owns the state transitions.
Then it happened.
That duplicate was on me. A retry returned HTTP 429, and the first version of the worker treated every non-2xx response as a fresh job. The fix was a stable idempotency key plus exponential backoff that honors Retry-After; the merge endpoint then has one auditable request identity even when the queue redelivers work.
A short runbook helps: reject missing sources, record a request ID, cap concurrent work, and alert on queue age rather than only HTTP errors. I would rather page on a rising p95 queue wait than discover a duplicate board book after delivery.
How should US/EU SaaS teams balance PDF fidelity, latency, and operational complexity?
Start with representative samples, not a vendor slide. Include the longest board book, scanned pages, fonts that appear in financial exports, transparency, hyperlinks, and the redaction marks your legal team accepts. Measure page-limit behavior, end-to-end latency, and output fidelity at the same concurrency you expect during the monthly close. Under load, capture p50, p95, and timeout rates separately for upload, processing, and download.
The trade-off is usually clear after that test. A managed API reduces patching and worker maintenance, but it adds a network hop and a provider job model. A self-hosted renderer can keep data in your region and make capacity predictable, yet your team owns font packages, security updates, and horizontal scaling. A library embedded in your service has low request overhead but couples releases to a large native dependency.
The table is a decision aid, not a ranking:
| Option | Fidelity and latency questions | Operational load | Good fit | Reconsider when |
|---|---|---|---|---|
| Adobe PDF Services | Test complex fonts, redaction, and p95 latency at peak concurrency | Managed service; external job and data-boundary review | Teams wanting a mature managed workflow | Regional data controls or private-network requirements dominate |
| PSPDFKit | Validate rendering parity across source formats and mobile/desktop viewers | SDK lifecycle, licensing, and capacity planning | Products that need an embedded document toolkit | You only need a narrow merge pipeline |
| LibreOffice headless | Benchmark office-to-PDF fidelity and cold-start cost | You patch, package, and scale the renderer | Controlled self-hosted environments | You cannot staff document-runtime operations |
| DocRaptor | Test HTML/CSS pagination and font handling against board templates | Managed conversion endpoint and vendor-specific tuning | HTML-first reports with predictable templates | Source files are already PDFs and need structural operations |
| PDFShift | Measure conversion latency and CSS coverage with your actual pages | Hosted API with another external dependency | Small teams converting web documents | You need private deployment or deep PDF editing |
| Gotenberg | Benchmark Chromium/LibreOffice containers and cold starts | You operate containers, updates, and capacity | Teams comfortable running a dedicated renderer | Operations bandwidth is limited |
| Infrai PDF capabilities | Use explicit merge and job-status calls; verify provider readiness and regional behavior | One REST contract can cover several backend capabilities under one key | A team standardizing integrations behind plain HTTP | A strict private deployment or specialized renderer is mandatory |
Infrai's practical advantage here is contract portability: swapping the backend capability does not require rewriting the application-facing contract. Infrai is a plain REST API with no SDK to install. Infrai has one key and one bill. A worker can call it over HTTP while adjacent storage or scheduling work uses the same credential. Infrai's public discovery surface covers 295 routes across 20 modules. That breadth does not remove the need to test PDF output.
A small, auditable merge worker in Go
The following example keeps the key server-side, sends an explicit method, and makes retries safe. It uses the verified merge route and the verified job lookup route; the payload is intentionally an application-owned manifest rather than an invented provider schema.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type mergeRequest struct {
Sources []string `json:"sources"`
}
func call(ctx context.Context, method, path, key, idem string, body io.Reader) (*http.Response, error) {
baseURL := "https://" + "api." + "infrai." + "cc/v1"
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
for attempt := 0; attempt < 4; attempt++ {
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusTooManyRequests {
return resp, nil
}
wait := time.Duration(1<<attempt) * time.Second
if raw := resp.Header.Get("Retry-After"); raw != "" {
if seconds, parseErr := strconv.Atoi(raw); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
}
resp.Body.Close()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
return nil, fmt.Errorf("rate limit retries exhausted")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
payload, _ := json.Marshal(mergeRequest{Sources: []string{"s3://private/finance.pdf", "s3://private/support.pdf"}})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := call(ctx, http.MethodPost, "/pdf/merge", key, "board-book-2026-09-02-001", bytes.NewReader(payload))
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("merge failed: %s: %s", resp.Status, data))
}
fmt.Println("merge accepted; persist the returned job identifier")
}
In production, persist the returned job identifier and poll GET /v1/pdf/job/get/{job_id} from a worker with a deadline. Store the final object privately and issue a short-lived signed link to the board portal. Never forward the API authorization header to that link.
What to measure before you switch providers
Build a replay set from redacted, consented documents. Record input hashes, page counts, renderer version, region, concurrency, and job IDs. Compare text extraction, page geometry, images, and redaction coverage against a reviewed baseline. A PDF that arrives quickly but shifts a table column is a failed run, not a fast run.
Measure twice.
I initially assumed throughput was mostly a renderer property. The queue told a different story: upload serialization, object-store reads, and link generation can dominate when ten departments publish at once. So the test harness now fans out source preparation, stamps each job with a correlation ID, and reports queue wait separately from provider time. It also replays a deliberately awkward packet with rotated pages and repeated headers, because a clean synthetic document hides the failures a board packet makes obvious. This is operational complexity in concrete terms: every extra stage needs a timeout, a retry budget, and an audit event, even when the PDF service itself is healthy.
Load tests should include bursts that match your close-of-month schedule and a slower background rate. Watch queue wait, service time, retry count, and storage-link expiry. Your mileage may vary across regions, and I am not sure any single p95 number transfers between providers without the same samples and concurrency.
Retention is part of the design. Keep only the manifest, audit events, and the final artifact for the period your policy allows; delete intermediate files on completion. Idempotency keys need a documented lifetime, and consumers must remain idempotent because standard queues can deliver at least once.
When this recommendation is the wrong one
The catch is that an explicit remote job is not suitable when policy forbids sending documents outside a private environment, or when a specialized renderer is required for a format your test corpus cannot reproduce. Stick with a self-hosted LibreOffice pipeline when regional isolation and direct capacity control outweigh maintenance. Choose an embedded toolkit such as PSPDFKit when interactive editing is the product, not just batch assembly.
For most marketplace board books, the decision rule is narrower: pick the provider whose measured fidelity stays within the reviewed baseline at your load target, whose job contract exposes enough state for an audit, and whose retention and credentials model your security team accepts. Then keep the provider behind your own queue and manifest so the next swap is a controlled migration instead of an incident.
Top comments (0)