DEV Community

AidenSterling3417
AidenSterling3417

Posted on

Python PDF Generation: 4 Load Tests for Hosted APIs and Local Libraries

Short answer: choose a hosted PDF API when invoice rendering is bursty, multi-tenant, or difficult to keep identical across workers; keep a local library when you need predictable low latency, offline operation, and full control of the renderer. For property-management invoices, signature verification and an append-only audit record matter more than whether the PDF came from a server or a process on your machine.

I build RAG and agent features in Python, so I treat report generation like any other production model dependency: start with a notebook, define an eval set, then measure the path that will run under load. An invoice is a particularly unforgiving report. A tenant can dispute a line item months later, and the useful evidence is the exact input, the rendered bytes, the signer, and the time each transition occurred.

When should hosted PDF APIs beat local libraries for report generation under load?

The answer depends on the shape of the queue, not on a benchmark headline. A local renderer has no network hop and can be very fast when a worker has warm fonts, templates, and enough CPU. Its latency becomes your problem during the 09:00 rent run: you must add workers, isolate memory-heavy jobs, package fonts, and make every worker produce the same bytes. A hosted API moves that capacity and patching boundary outside your process, but adds connection setup, remote queueing, rate limits, and an external dependency to observe.

For an invoice service, I make the decision with four measurements: p50 and p95 latency at the expected concurrency, the fraction of requests that exceed the billing deadline, byte-for-byte or visually equivalent output across retries, and the time needed to prove who signed which version. A single average hides the queue. A p95 of 1.2 seconds is fine for an interactive preview; a p99 spike to 40 seconds can still break a nightly statement batch.

The data flow should be boring. Normalize an order into a versioned invoice object, render from that immutable object, hash the resulting bytes, request a signature over the hash, and write an audit event before delivering the download. Keep the PDF in object storage or a controlled filesystem; keep the audit event in a database with a unique invoice-version key. The renderer is one stage, not the source of truth.

Here is the production-shaped example I use in design reviews. At 09:00, a property manager selects 8,000 open orders across 240 buildings. The API accepts the batch and places one immutable invoice version per order on a queue; the web request never waits for 8,000 PDFs. A worker claims one item, renders it, computes pdf_sha256, and writes a row with a unique (invoice_id, version) constraint. If the worker dies after rendering but before the audit insert, the next attempt sees the same idempotency key and either reuses the stored bytes or safely replaces an uncommitted object. A signer service receives the digest, returns a signature identifier, and the worker appends signed with the same correlation ID. The download endpoint checks that the invoice is signed, fetches the exact object named by the audit row, and streams it as application/pdf. Operators can then answer four separate questions: how long did rendering wait, which bytes were signed, who approved them, and whether a retry changed anything. That separation is what keeps a slow hosted call from turning into a duplicate charge or an unverifiable statement.

Measure twice.

A Python render-and-sign boundary I can evaluate

This small interface lets the same test harness exercise a local adapter and an HTTP adapter. The hosted implementation is intentionally generic: an API contract should define timeouts, idempotency, and a response containing PDF bytes or a retrievable object reference. Do not let vendor-specific response fields leak into the invoice domain model.

from dataclasses import dataclass
from hashlib import sha256
from typing import Protocol


@dataclass(frozen=True)
class Invoice:
    invoice_id: str
    version: int
    tenant_id: str
    total_cents: int
    lines: tuple[tuple[str, int], ...]


class PdfRenderer(Protocol):
    def render(self, invoice: Invoice) -> bytes:
        """Return deterministic PDF bytes for one immutable invoice version."""


def audit_payload(invoice: Invoice, pdf_bytes: bytes, signer_id: str) -> dict:
    digest = sha256(pdf_bytes).hexdigest()
    return {
        "invoice_id": invoice.invoice_id,
        "version": invoice.version,
        "tenant_id": invoice.tenant_id,
        "pdf_sha256": digest,
        "signer_id": signer_id,
    }


def render_once(renderer: PdfRenderer, invoice: Invoice, signer_id: str) -> tuple[bytes, dict]:
    pdf = renderer.render(invoice)
    if not pdf.startswith(b"%PDF-"):
        raise ValueError("renderer returned a non-PDF payload")
    return pdf, audit_payload(invoice, pdf, signer_id)
Enter fullscreen mode Exit fullscreen mode

The important property is determinism at the boundary. If a retry produces different bytes because a timestamp or random identifier was embedded in the template, the hash changes and the signature no longer describes the same document. Put display time in a field with an explicit business meaning, or freeze it in the invoice object before rendering. In my eval harness, I render the same fixture 20 times and compare hashes before I test throughput.

The Blob interface is a useful browser-side reminder here: a PDF is bytes with a MIME type, not a magical document object. The browser can create a Blob and download it, but that does not establish authorship or integrity. Those properties come from the hash, signature, and audit record your service controls.

What breaks first at production scale?

Latency is usually a queueing problem. With local libraries, CPU saturation, font loading, and process-level memory growth stretch the tail. With a hosted API, network transit and the provider's queue join that list. Measure each segment separately: enqueue time, connect time, time to first byte, download time, and signature time. Carry a correlation ID through all of them. A timeout should leave an explicit render_pending event, never a half-written invoice row.

Retries need an idempotency key such as invoice_id:version. Without it, a client timeout can cause two renders, two storage objects, and two audit events even though the user clicked once. A local worker can enforce uniqueness in its database; an HTTP service must send the key and verify that a repeated request returns the same logical result. Backoff should respect Retry-After when the API supplies it, and the overall deadline should be shorter than the batch window.

Signature placement is another trade-off. A cryptographic signature over the PDF bytes is strong evidence of integrity, while a visible name and date are only presentation. If a regulator needs long-term validation, store the certificate chain or a durable reference to it with the audit event. If the business only needs an internal approval trail, a signed hash plus immutable event storage may be sufficient. I am not sure which retention period your jurisdiction requires; have counsel map the rule to a concrete deletion and key-rotation policy.

Fidelity deserves its own test. Local HTML-to-PDF tools can differ by operating-system font, browser version, and sandbox flags. A hosted service may standardize that environment, yet its template engine or CSS support can constrain layouts you already have. Keep golden PDFs and rendered screenshots for representative invoices: long tenant names, tax exemptions, negative adjustments, right-to-left text, and a 200-line utility bill. Compare semantics and visual layout, not just file size.

A decision table for the invoice team

Concern Local PDF library Hosted PDF API
Cold-start and burst capacity You provision workers and warm assets Capacity is an external dependency; verify quotas and queue behavior
Tail latency Sensitive to your CPU and memory limits Sensitive to network and remote queueing
Data boundary Bytes stay inside your environment Invoice data crosses a service boundary; review residency and retention
Renderer control Full control of binaries, fonts, and flags Faster standardization, less control of the rendering stack
Audit integration You own storage, signing, and replay You still own the audit record even if rendering is remote
Failure recovery Retry a job in your queue Handle timeouts, rate limits, and idempotent retries explicitly

The catch is that a hosted API is not suitable when invoices contain data that cannot leave your controlled boundary, when an offline site must keep issuing documents, or when your measured load is steady enough that operating a local renderer is simpler. Stick with a local library in those cases, and invest in reproducible containers plus a font manifest. Choose hosted rendering when burst capacity and operational consistency outweigh the network hop, and only after a load test demonstrates acceptable p95 and p99 behavior.

My operational checklist is short prose because it belongs next to the runbook: pin the renderer version or image, freeze invoice inputs, record hashes and correlation IDs, enforce one audit event per invoice version, alert on tail latency and pending jobs, and rehearse a replay from the immutable input. Then run the four tests again after every template or dependency change. Reports are software; the signature trail is the feature.

References

Top comments (0)