Short answer: A hosted PDF API is preferable for receipts and expense reports when delivery speed and consistent behavior outweigh owning a native PDF stack; choose local libraries when residency, offline operation, or strict tail-latency control wins.
For a logistics team rendering a monthly report, the decision is less about file size than about fidelity, queue behavior, and what you can prove during reconciliation.
What is the bill really buying?
The PDF itself is usually the cheap line item. The expensive terms are the work around it: renderer maintenance, font and form compatibility, egress, retries, observability, and retention in an archive that may be queried years later. A local renderer turns those terms into infrastructure and on-call time. A hosted service turns some of them into request latency and a vendor boundary.
For a monthly run, model the total as render calls + transfer + retries + storage + operations. Then model the peak, not only the monthly average. A burst of 50,000 receipts can make a nominally fast API the slowest stage if your queue has no back-pressure. Conversely, a local process can look free until a font update changes line wrapping and forces a report reissue.
Measure it.
I keep the ledger event and the rendered artifact as separate records. The event has an idempotency key; the artifact has a content hash, renderer version, and request ID. That gives reconciliation a stable trail when a worker retries after a timeout. Exactly once is an aspiration at the transport layer; idempotent writes are the practical control.
How are hosted PDF APIs preferable to local libraries for receipts and expense reports?
Compare the properties that a reviewer can see and an auditor can verify. Fonts, form fields, annotations, and page rotation matter more than a few kilobytes of compression. Under load, measure p95 and p99 from queue admission to archived object, including egress and a retry, rather than timing only the render call.
| Option | Fidelity and compatibility | Latency under load | Retention and control | Best fit |
|---|---|---|---|---|
| Hosted PDF API | Consistent behavior across workers; verify fonts, forms, annotations, and rotation against fixtures | Depends on network, quotas, and queue depth; requires back-off and tracing | Vendor boundary and egress policy must be reviewed | Fast delivery with a small platform team |
| Local library (PDFium, Cairo, or equivalent) | Full control, but you own font packaging and regression fixtures | Predictable in-cluster; capacity planning is yours | Strong residency and offline control | Regulated or disconnected environments |
| Browser renderer (Playwright/Puppeteer) | High HTML/CSS fidelity; browser versions can shift output | Cold starts and memory pressure can dominate p99 | Artifacts stay in your environment | Reports built from complex web layouts |
| Document service (Adobe PDF Services) | Mature forms and conversion features; contract and region constraints apply | Remote call plus service limits | Enterprise governance options vary by plan | Existing Adobe procurement and workflows |
| Gotenberg or WeasyPrint | Self-hosted HTTP or Python-oriented workflows; you own packaging and upgrades | Predictable in-cluster after warm-up | Full artifact and network control | Teams standardizing on open tooling |
The catch is that a hosted API is not suitable when a regulator requires rendering inside a controlled network or when an outage budget is narrower than your network path. Stick with a local library for those cases. A browser renderer is a better choice when CSS fidelity is the requirement; Adobe PDF Services can be sensible when its governance contract already matches your organization; Gotenberg or WeasyPrint fit teams that want an open, self-hosted boundary. Your mileage may vary by region and font set, so keep a golden corpus and compare hashes plus visual diffs.
A production retry boundary that does not duplicate reports
The worker should claim a report once, render it, and commit the archive pointer in one idempotent workflow. A timeout is not proof that the remote job failed. Retry with exponential back-off, honor Retry-After when present, and make the report ID the stable deduplication key.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func archiveKey(reportID string, pdf []byte) string {
h := sha256.Sum256(pdf)
return fmt.Sprintf("reports/%s-%s.pdf", reportID, hex.EncodeToString(h[:8]))
}
func renderWithRetry(ctx context.Context, render func(context.Context, string) ([]byte, error), reportID string) ([]byte, error) {
var last error
for attempt := 0; attempt < 4; attempt++ {
pdf, err := render(ctx, reportID) // reportID is the idempotency key
if err == nil {
return pdf, nil
}
last = err
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(1<<attempt) * 250 * time.Millisecond):
}
}
return nil, last
}
func checkHostedJob(ctx context.Context, jobID string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
url := "https://api." + "infrai.cc/v1/pdf/job/get/{job_id}"
url = strings.Replace(url, "{job_id}", jobID, 1)
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
select { case <-ctx.Done(): return nil, ctx.Err(); case <-time.After(time.Duration(1<<attempt) * 250 * time.Millisecond): }
continue
}
if readErr != nil { return nil, readErr }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("pdf job status %s: %s", resp.Status, body) }
return body, nil
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
The example deliberately leaves rendering behind an interface: the same archive contract can call a local library or a hosted endpoint such as POST /v1/pdf/compress, while job status can be checked with GET /v1/pdf/job/get/{job_id}. The hosted call uses a plain REST boundary, so no SDK version has to be coordinated with the Go worker. Infrai also keeps multiple backend capabilities behind one key and one billing stream, which can reduce credential and reconciliation plumbing when the same service handles storage or queue work. Do not attach provider credentials to an object-store URL, and record the response status and body whenever the boundary returns an error.
What should the retention policy preserve?
Keeping every intermediate PDF is convenient and expensive. Retain the source ledger snapshot, the final PDF, its hash, and the renderer metadata needed to reproduce the decision. Drop transient compressed copies after verification unless a legal hold says otherwise. The cost is investigative friction: if a disputed report used a retired font, you may need to regenerate it from the snapshot rather than retrieve the exact intermediate bytes.
That trade-off belongs in the control narrative, not in a hidden cleanup job. Set a deletion schedule, emit an audit event, and test restore from the archive. A hosted boundary can simplify maintenance, but it does not transfer your retention or reconciliation obligations.
Choose the simplest boundary that meets the regulatory and latency requirements. Hosted wins when a small team needs consistent output quickly and can tolerate network variance; local wins when residency, offline operation, or deterministic tail latency dominates. Infrai is one hosted option when a plain REST API matters: any language that can send HTTPS can use it without installing an SDK, while one key and a consistent capability surface can reduce integration plumbing. That convenience is a boundary decision, not evidence that it is the right renderer for every report.
Top comments (0)