DEV Community

YancySterling6529
YancySterling6529

Posted on

Receipt and Expense PDF APIs — Measuring Fidelity and Latency Under Load

Use an asynchronous PDF job with a bounded worker pool when a US or EU SaaS must render receipts and expense reports under load. Keep a synchronous endpoint for a single preview, but send production batches through a queue and archive immutable output. The deciding constraint is tail latency, not the average response time.

Short answer: choose the endpoint contract that lets you measure p95 and p99 render time, preserve the source data, and retry safely. Fidelity is a release gate; latency and operational complexity are the costs you tune around it.

What does a receipt and expense PDF endpoint need to guarantee?

Start with a contract rather than a renderer. A request should carry a template version, locale, currency, input data, and an idempotency key. The response for a preview can be a PDF stream. The response for a batch should be a job identifier with explicit states such as queued, running, succeeded, and failed. Never make the caller guess whether a timeout created a second document.

For US and EU tenants, retain the original receipt image or HTML input beside the generated PDF. Store a content hash, template version, creation timestamp, and the identity of the producing worker. That metadata makes an audit reproducible when a tax reviewer asks why a number moved.

The binary itself needs boring HTTP behavior: Content-Type: application/pdf, a stable Content-Length when you stream from storage, and a Content-Disposition filename that is safe for both / and \\. Return a correlation ID in every response. A 202 for accepted work is useful only when the status endpoint and retention policy are documented.

The browser side should treat the body as bytes, not text. The Blob API exposes a portable way to hand those bytes to a download flow, and it works without tying the client to a particular PDF library.

export async function downloadPreview(endpoint: string): Promise<void> {
  const response = await fetch(endpoint, { headers: { Accept: "application/pdf" } });
  if (!response.ok) throw new Error(`PDF preview failed: ${response.status}`);

  const blob = await response.blob();
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = "expense-report-preview.pdf";
  link.click();
  URL.revokeObjectURL(url);
}
Enter fullscreen mode Exit fullscreen mode

How should fidelity, latency, and operational complexity be balanced under load?

I score each candidate endpoint against the same corpus: a 40-page monthly media report, a receipt with a long merchant name, a German address, a French decimal comma, a right-to-left note, and a deliberately missing image. The corpus lives in version control with golden PDFs and extracted text. A pixel diff catches clipping; text extraction catches a value that looks right but is not selectable. Your mileage may vary on rendering time because fonts and image sizes dominate the result.

Run the corpus at 1x, 2x, and 5x the expected concurrency. Record p50, p95, p99, queue wait, render time, output bytes, and retry count. A renderer that wins p50 but blows past a two-second p99 creates more support work than it removes. Set separate budgets for preview and archive jobs; users can tolerate a queued monthly archive, while an expense submit button usually cannot.

There is a real tradeoff:

Choice Fidelity signal Latency behavior Operational burden
In-process renderer Fast feedback for simple layouts Sensitive to CPU spikes in web workers Deploys and crashes share one pool
Dedicated render service Controlled fonts and repeatable output Queue absorbs bursts; tail time is visible Capacity, health checks, and versioning
Managed PDF endpoint Often broad format support Network and quota limits become part of p99 Vendor contract, data residency, and incident process

The catch is that no endpoint removes the need for a queue, a timeout, and a dead-letter path once reports become batch work. A synchronous-only design is not suitable when a tenant can submit thousands of receipts at once. Stick with it for low-volume previews where an extra job state would make the product feel slower.

Which failure modes make PDF latency look random?

Most surprises are inputs. A 12 MB phone photo can cost more than the HTML around it. A missing font can trigger fallback and alter line breaks, turning a one-page receipt into two pages. Repeated remote image fetches add variance and create a hidden dependency on another region. Normalize images, bundle approved fonts, and cache immutable assets before the render starts.

Timeouts need ownership. The API gateway may stop waiting at 10 seconds while the worker continues for another minute. Pass a deadline to the job, persist the final state, and make retries idempotent. On retry, reuse the same key and content hash; do not create a second archive object because a client lost a TCP packet. In a media batch, this matters when a report contains hundreds of thumbnail fetches: the first attempt can finish rendering after the caller has already retried, and two workers can then race to publish different bytes under one human-readable filename. Persist the winning checksum, make the object write conditional on that checksum, and have later retries return the recorded result. The extra bookkeeping is small; reconciling duplicate tax documents is not.

Then it breaks.

Observability should separate queue wait from render time. Emit one span for validation, one for asset preparation, and one for rendering, with tenant and template labels that are cardinality-bounded. Alert on p99 and queue age, not only error rate. A green 200 rate can hide a report that arrives after the user has closed the tab.

Small teams often discover this during a month-end close: queue age stays flat, but asset preparation quietly consumes the worker budget because every receipt is decoded again. I sample those spans by template version, inspect the largest payloads, and move repeated work into a content-addressed cache before touching the renderer. That sequence keeps the diagnosis concrete and avoids “fixing” latency by throwing more CPU at an unbounded input path.

What should the archive and compliance boundary look like?

Write the PDF to object storage only after validation and a successful checksum. The archive record should point to an immutable object version, while a short-lived download URL serves the user. Encrypt in transit and at rest, apply region-aware retention, and keep deletion events auditable. US and EU policies differ by tenant, so retention belongs in configuration, not in a hard-coded seven-year constant.

Keep raw receipt data and derived PDFs on separate access paths. Support staff may need to see a failed template version without gaining unrestricted access to every image. Redact logs; merchant names and invoice totals are not harmless debugging strings.

Before rollout, replay the golden corpus after every renderer or font change. Compare page count, extracted text, bounding boxes for totals, and file hash stability. A changed hash is not automatically a failure, but a changed total or clipped tax line is.

A decision rule for a small SaaS team

Start with one synchronous preview route and one asynchronous batch route behind the same template and validation code. Cap per-tenant concurrency, expose queue age, and reserve worker capacity for interactive previews. That shape keeps the first release small while leaving a clear path to regional workers when load grows.

I would choose the simplest endpoint that passes the corpus at the required p99 and meets your data-residency contract. If it cannot, add isolation before adding features. If the team cannot operate fonts, retries, and archival retention, a managed service may be the responsible choice; its network, quota, and residency limits still belong in your risk register.

Measure first. Then commit.

References

Top comments (0)