Customer-support teams often discover the PDF problem during a billing spike: the HTML page looks correct in a browser, yet the invoice PDF has a clipped table, a blank second page, or a footer sitting on top of totals. Short answer: treat PDF generation as a print-layout and document-serialization pipeline, then capacity-plan the batch worker around pages, fonts, and memory rather than around HTTP request count.
I learned this the expensive way while reviewing a support platform's monthly invoice export. The renderer was healthy, the HTML snapshot tests were green, and a queue of 18,000 orders still produced 73 malformed files. The trigger was an innocent CSS change: a long customer address increased one row's height, which pushed a page-break-inside: avoid block past the printable area. The browser reflowed interactively; the PDF engine had to choose a finite page geometry and a pagination policy. Those are different contracts.
The invariant was simple: a successful render is not the same thing as a valid invoice document. A production path needs deterministic inputs, explicit print rules, post-generation checks, and a retry model that cannot duplicate an invoice.
That distinction is easy to miss.
Why does print layout make PDF generation harder than rendering HTML?
HTML is a living layout tree. It can defer image loading, measure against a changing viewport, and let a user scroll past content. PDF is a fixed sequence of pages with coordinates, resources, and cross-reference data. ISO 32000-2 describes a document format whose pages, fonts, annotations, and object references must remain internally consistent after serialization. There is no viewport to rescue an element that does not fit.
That difference creates failure modes that ordinary browser tests miss. Imagine an export where the first page contains 34 line items and the next page starts with a tax note; a single address with four wrapped lines can move the note into the footer reservation, so the renderer correctly makes a new page while an assertion incorrectly expects two pages. The remedy is to assert content order and reserved geometry, then add a 200-line synthetic order that exercises the same boundary on every release. The point is not to force a two-page result; it is to make the pagination decision observable and stable when data shape changes.
Here are the recurring cases:
| Failure mode | Why a browser screenshot misses it | Control to add |
|---|---|---|
| Font fallback changes line wrapping | CI may have the font; the worker image may not | Package and hash fonts, then inspect the rendered text width |
| Table crosses a page boundary | A viewport screenshot has no page boundary | Use print CSS and test at the target paper size |
| Transparent image becomes huge | The browser can downsample for display | Normalize image dimensions before embedding |
| Footer overlaps totals | Position is relative to a page, not a scrolling box | Reserve a footer region and assert bounding boxes |
| Corrupt cross-reference table | Image-based tests still show pixels | Open the file with a PDF parser and count pages |
The practical implication is to freeze the print environment. Set the page size, margins, color policy, font files, locale, and timezone as versioned configuration. A change to any of those is a document-format change and deserves the same review as a database migration.
A batch architecture that survives support-volume spikes
For invoice PDFs, the unit of work should be an immutable order snapshot, not a live customer record. Store the snapshot and a document version, enqueue a job keyed by (account_id, order_id, template_version), and write the resulting bytes to object storage under a content-addressed key. A worker can then retry safely: the same input and version map to the same output key.
Capacity planning starts with pages per minute and peak queue age. If one worker renders 42 pages per minute at its p95, and the monthly run contains 126,000 pages, the steady estimate is 3,000 worker-minutes. Add headroom for font loading and retries; an SLO such as “99% of invoices available within 15 minutes” is more useful than a vague target of “fast PDFs.” Track p50 and p95 render duration, page count, bytes per page, queue age, and validation failures.
Here is a deliberately small Go worker skeleton. It separates rendering from validation and makes idempotency visible to the caller.
package main
import (
"context"
"crypto/sha256"
"fmt"
)
type Snapshot struct {
AccountID string
OrderID string
TemplateVersion string
HTML []byte
}
type Renderer interface {
Render(ctx context.Context, html []byte) ([]byte, error)
}
type Validator interface {
Validate(pdf []byte) error
}
func objectKey(s Snapshot) string {
h := sha256.Sum256(s.HTML)
return fmt.Sprintf("invoices/%s/%s/%s-%x.pdf", s.AccountID, s.OrderID, s.TemplateVersion, h[:8])
}
func Build(ctx context.Context, s Snapshot, r Renderer, v Validator) (string, []byte, error) {
pdf, err := r.Render(ctx, s.HTML)
if err != nil {
return "", nil, err
}
if err := v.Validate(pdf); err != nil {
return "", nil, fmt.Errorf("document validation: %w", err)
}
return objectKey(s), pdf, nil
}
The renderer can be a headless browser, a dedicated PDF library, or a service behind an internal interface. The choice belongs behind this boundary because the operational risks are mostly the same: process isolation, bounded concurrency, deterministic assets, and a parser-based validation step. Keep the worker's concurrency below the point where font caches and raster images exhaust memory; a useful first limit is one render per CPU core, then tune from observed p95 memory rather than from hope.
How should teams test pagination, fonts, and invoice correctness?
Use three layers. First, unit-test the data-to-HTML transformation with fixed snapshots. Second, render a small corpus at the exact paper size and compare structural facts: page count, text presence, metadata, and bounding boxes. Third, run visual diffs on a curated set of difficult invoices: long addresses, non-Latin names, zero-line discounts, 200-line orders, and tax notes that wrap to a second page.
Pixel diffs alone are noisy because anti-aliasing and font rasterization vary across operating systems. Structural checks catch a different class of defect. For example, a parser can assert that page count is between one and five, that every invoice number appears exactly once, and that the trailer contains the expected total. Reject the artifact before publishing it; do not ask a support agent to discover a malformed file in a customer conversation.
I also keep a “layout budget” in the template repository: maximum logo dimensions, maximum line-item columns, and the reserved footer height. It sounds fussy. It prevents a late marketing request from silently consuming the 24-pixel margin that made the totals readable.
Choosing a renderer without hiding the trade-offs
There is no universal winner. A headless browser is attractive when the team already owns HTML and CSS, but its output depends on browser version, installed fonts, and sandbox policy. A native PDF library gives tighter control over coordinates and memory, at the cost of rebuilding layout primitives. A hosted conversion API reduces patching and browser operations, while adding network dependency, data-residency review, and a per-document failure boundary.
| Option | Strength | Cost or limitation | Fit for batch invoices |
|---|---|---|---|
| Headless browser | Reuses existing web templates | Larger workers and CSS-version drift | Good when templates change often |
| Native PDF library | Predictable geometry and low runtime overhead | More engineering for tables and pagination | Good for stable, high-volume forms |
| Hosted conversion service | Small operations footprint | Network, residency, and vendor lock-in concerns | Good when queue latency is less strict |
The catch is that a renderer is not suitable when its execution model conflicts with your SLO. If invoices contain regulated data that cannot leave your network, a hosted service is out. If the team cannot maintain browser images and font packages, self-hosting a browser may be the wrong buy. Stick with the option whose failure mode your on-call rotation can actually diagnose.
The production checklist I would put in the runbook
Before enabling a new template, pin the renderer image, record the font hashes, and render representative snapshots at the supported paper sizes. During a batch, expose queue age and an explicit count of validation failures; alert on the SLO, not merely on process liveness. After the batch, reconcile order IDs against object-storage keys and retain the template version with the invoice record.
One last judgment call: do not optimize for the smallest PDF first. Optimize for a repeatable document that a customer can print, archive, and retrieve six months later. Your mileage may vary when invoices are only internal previews, but for customer-facing billing, deterministic pagination and a verifiable artifact are the real definition of done.
Top comments (0)