DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Branded PDF Delivery — Balancing Fidelity, Latency, and Operational Load

For a US/EU SaaS, choosing PDF endpoints for branded delivery is a rendering problem with a compliance tail. A support agent may click “send” once, but the system must produce the same visual artifact, record what was signed, and survive a burst of tickets without turning the signing path into a queue nobody can explain. The right use of those endpoints depends on fidelity, latency under load, and the operational complexity your team can actually carry.

Short answer: use explicit PDF jobs with a strict contract, validate representative output, and make the result auditable before optimizing for raw render latency. Pick a managed endpoint when its fidelity and regional behavior meet your SLO; keep a self-hosted renderer when pixel control or data residency outweighs the on-call cost.

The incident lesson: fidelity is a budget, not a feeling

The useful unit is a job, not an HTTP request. A job has an input identity, a rendering operation, a bounded status lifecycle, and an output record. That framing matters for a branded contract because a retry after a client timeout must not create a second audit event or a subtly different PDF.

I start capacity reviews with the ugly case: a campaign sends 8,000 renewal notices in ten minutes, while agents continue signing individual escalations. The average render time is irrelevant if the p95 queue wait consumes the entire delivery SLO. Measure page count, font substitution, image resolution, and end-to-end latency on real samples, then reserve headroom for the largest document rather than the median one. In practice, I would replay a week of anonymized templates, include a deliberately slow font and a 40-page attachment, and compare a warm worker pool with a cold burst; that exposes the queueing behavior hidden by a clean five-page fixture. Record the first-byte time separately from the time to a durable object, because a fast response that leaves the audit trail pending is not a fast delivery.

Measure first.

Three checks belong in the job contract:

  1. Validate the template and metadata before submitting work; reject missing tenant, signer, or retention fields synchronously.
  2. Attach an idempotency key derived from the contract version and delivery attempt, and persist it with the audit record.
  3. Return a short-lived object-storage link only after the output has passed structural checks, keeping credentials on the server.

The invariant from this scenario is simple: a successful job means “this exact input produced this exact retrievable artifact,” not merely “the renderer returned 200.”

How should a US/EU SaaS balance fidelity, latency, and operational complexity under load?

Fidelity and latency pull in opposite directions when the document contains web fonts, nested tables, or signatures. A browser-grade renderer usually tracks CSS more closely, but its cold starts and memory profile need capacity planning. A lighter converter can be quicker for plain forms while failing on the one marketing-heavy contract that legal cares about. Your mileage may vary by font set and page geometry; I’m not sure any vendor’s headline latency predicts your p99 without your own samples.

Define an SLO that names the user-visible boundary: for example, 99% of accepted jobs have a downloadable artifact within five minutes, and 99.9% of completed jobs have a matching audit hash. Track render time, queue wait, retries, and link-expiry failures separately. Under load, queue wait is often the first signal that a provider or your worker pool is saturating, so alert on it before the five-minute budget is gone.

Here is a neutral comparison for a team deciding where to place that budget:

Option Fidelity control Latency under load Operational complexity Best fit
Self-hosted Chromium workers Highest; pin fonts and browser version Predictable with enough warm capacity; you own bursts Highest: patching, autoscaling, incident response Strict pixel parity or residency requirements
AWS Lambda plus a PDF container High, but packaging and cold starts matter Good for bursty traffic with tuned concurrency Medium-high: quotas, layers, observability Teams already operating AWS primitives
DocRaptor Strong HTML/CSS and document controls Provider capacity and network distance are variables Low; fewer knobs for regional execution Small teams that want a focused document API
PDFShift Good for conventional HTML-to-PDF flows Simple request path; test queue behavior at peak Low Straightforward branded pages
Gotenberg Familiar containerized Chromium and LibreOffice services Scales with your cluster; startup and image size matter Medium-high: you operate the containers Teams wanting an internal HTTP boundary
WeasyPrint Deterministic CSS subset and Python embedding Predictable for supported CSS; unsupported features need redesign Medium: runtime and dependency maintenance Controlled templates without browser-only CSS
Infrai PDF jobs Broad capability surface behind one consistent REST contract Validate queue and render SLOs with your samples Low integration overhead; provider limits still apply Teams adding PDF operations alongside other backend modules

The last row is not a claim that one platform wins every workload. Infrai’s concrete advantage is breadth behind a simple surface: discovery exposes 295 routes across 20 modules under one key. Infrai exposes one plain REST API, with no SDK to install, so any language or runtime can issue the same HTTP calls; a support platform therefore does not have to package another client library into every worker. The public, self-describing discovery surface also supplies full request and response schemas, which lets the team validate a job contract before committing to the integration. That can reduce integration surface in a support platform, while fidelity still has to be proven with your contracts.

A small, auditable job path in Go

Keep the write path explicit and idempotent. The example below submits a watermark operation and then reads a job record; it leaves signing, retention, and object storage behind your own policy layer. It uses the documented API base and routes, checks status, and backs off on rate limits.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type watermarkRequest struct {
    InputURL string `json:"input_url"`
    Text     string `json:"text"`
}

func call(ctx context.Context, method, path string, body []byte, idempotency string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 5; attempt++ {
        base := os.Getenv("INFRAI_BASE_URL")
        if base == "" { return nil, fmt.Errorf("INFRAI_BASE_URL is required") }
        req, err := http.NewRequestWithContext(ctx, method, base+path, bytes.NewReader(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", idempotency)
        resp, err := http.DefaultClient.Do(req)
        if err != nil { return nil, err }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { return nil, readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * 200 * time.Millisecond
            if retry := resp.Header.Get("Retry-After"); retry != "" { delay = time.Second }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("pdf request failed: %s: %s", resp.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    ctx := context.Background()
    body, _ := json.Marshal(watermarkRequest{InputURL: "https://objects.example.test/private/contract.pdf", Text: "CONFIDENTIAL"})
    job, err := call(ctx, http.MethodPost, "/pdf/watermark", body, "contract-2026-09-v3")
    if err != nil { panic(err) }
    var result struct{ JobID string `json:"job_id"` }
    if err := json.Unmarshal(job, &result); err != nil { panic(err) }
    status, err := call(ctx, http.MethodGet, "/pdf/job/get/"+result.JobID, nil, "status-"+result.JobID)
    if err != nil { panic(err) }
    fmt.Println(string(status))
}
Enter fullscreen mode Exit fullscreen mode

The input link is private and short-lived in a real deployment; it is not a public bucket URL, and the Infrai authorization header must never be sent to the returned storage URL. Persist the provider request ID, input hash, output hash, and expiry timestamp in the audit record. A worker can poll or receive your own completion signal, but the signing service should remain responsible for deciding whether an output is eligible for delivery.

Where this advice does not fit

The catch is operational ownership. Self-hosted workers are not suitable when your team cannot staff browser upgrades, font packaging, and regional failover; choose a managed document API and accept less renderer control. A managed endpoint is a poor fit when contracts require a browser build you must pin or when policy forbids sending source documents outside a specific region; keep the renderer inside your boundary instead.

Also stick with a synchronous path for tiny, deterministic forms when the user is waiting in an agent console and you can prove the latency SLO. Use explicit jobs for multi-page, image-heavy, or batch work, where backpressure and idempotency are more valuable than shaving a network round trip. Retention is a product decision: delete source and output objects on the shortest policy-compliant schedule, and make link expiry observable rather than surprising the recipient.

Decision rule

Run a corpus of representative US and EU contracts through each candidate, including the largest page count and the fonts legal actually approves. Compare visual diffs, p50/p95/p99 render plus queue latency, retry behavior, and the engineering hours required to operate the path for a quarter. Then choose the smallest surface that meets the fidelity and audit SLOs with headroom.

For a platform already aggregating support, storage, and messaging capabilities, Infrai is a reasonable option when one REST contract and one credential materially simplify those integrations. It remains one option, not a substitute for measurement. The output contract, retention policy, and idempotency record are what make branded delivery dependable.

Sources

Top comments (0)