DEV Community

ethanbrooks1486
ethanbrooks1486

Posted on

PDF Endpoints for SaaS Receipts and Expense Reports: Fidelity vs Latency

Receipts are small until a finance team asks for a month's worth in one PDF. Then template ownership, render fidelity, and queue latency become one problem. For a US/EU gaming SaaS, I would make PDF generation an explicit job with a strict contract, keep the template in our repository, and archive the resulting object behind a short-lived link. That choice keeps the rendering decision reversible while traffic is still moving.

Start there.

Short answer: use asynchronous PDF jobs for batch reports, validate every input before enqueueing, and measure fidelity and latency with real receipt samples under load. Pick a managed endpoint when the team values low glue and shared operations; pick an in-process renderer when pixel ownership is the product.

What changes when a receipt becomes a monthly report?

The operation is not “turn HTML into bytes.” It is a small accounting pipeline: collect line items, freeze a template version, render, verify the output, then retain an auditable artifact. A synchronous request is tempting for a single receipt, but a 500-page expense report can hold a web worker hostage. A job contract gives us a stable boundary: queued, running, succeeded, or failed, with a request id and a retention deadline.

Template ownership is the first fork. With a repository-owned template, a pull request can review a tax label or a decimal-format change, and a report can record the exact commit. A provider-owned template reduces code, but the visual contract lives elsewhere. That is fine for a back-office export; it is uncomfortable when a game publisher promises a branded statement to auditors.

Do the boring checks early. Reject an unknown currency, an overlong merchant name, or a missing tax jurisdiction before work enters the queue. Store the source data and the output checksum separately. Keep credentials on the server. Give a browser a short-lived object-storage URL, never the service credential.

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

I would benchmark three things with representative receipts: visual diffs against a golden PDF, p50/p95 job completion time, and the failure behavior when the queue is full. The numbers matter more than a vendor's demo. I am not sure a single global p95 is meaningful for every region; your mileage may vary, so split the test by US and EU execution paths and by page count.

Measure twice.

For a useful load test, I would replay a fixed corpus rather than generate synthetic lorem ipsum. Include a one-page receipt with a long merchant name, a multi-page expense report with a table split across pages, a receipt containing a non-Latin player name, and a report with an intentionally missing tax field. Send the same corpus through each candidate at the concurrency your launch calendar suggests, then retain the PDFs and their hashes. Compare rendered pixels for fidelity, but also inspect selectable text and page count because a visually similar page can still break downstream extraction. Record queue wait separately from render time; otherwise a fast renderer hidden behind a saturated queue looks slow. I don't trust a single average here. A p95 that doubles only after the fifth worker is a capacity signal, while a stable p95 with occasional malformed output is a correctness signal. Those lead to different fixes, and the report should keep them separate.

Here is the smallest polling client I would put behind a worker. It uses only the documented compress and job lookup paths; the request schema should be checked through discovery before wiring a production payload.

const baseUrl = process.env.PDF_API_BASE_URL ?? "https://pdf.internal.example";
const apiKey = process.env.INFRAI_API_KEY;

async function request(path: string, init: RequestInit, attempt = 0): Promise<Response> {
  const response = await fetch(`${baseUrl}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Idempotency-Key": crypto.randomUUID(),
      ...(init.headers ?? {})
    }
  });
  if (response.status === 429 && attempt < 5) {
    const retryAfter = Number(response.headers.get("retry-after") ?? "0");
    const delayMs = Math.max(retryAfter * 1000, 250 * 2 ** attempt);
    await new Promise(resolve => setTimeout(resolve, delayMs));
    return request(path, init, attempt + 1);
  }
  if (!response.ok) throw new Error(`PDF request failed: ${response.status} ${await response.text()}`);
  return response;
}

export async function compressPdf(pdfBytes: Uint8Array, jobId: string) {
  await request("/v1/pdf/compress", {
    method: "POST",
    headers: { "Content-Type": "application/pdf" },
    body: pdfBytes
  });
  return request(`/v1/pdf/job/get/${encodeURIComponent(jobId)}`, { method: "GET" });
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key belongs to the logical report, not an HTTP retry. In real code I would derive it from the report id and template version, persist it with the job, and poll with a bounded schedule. The snippet deliberately leaves payload fields out: inventing a field that is not in the endpoint schema is worse than one extra discovery lookup.

Which PDF approach fits the ownership decision?

Approach Fidelity and ownership Latency under load Operational cost
Playwright/Chromium service Full browser fidelity; templates stay in your repo Cold starts and memory need capacity planning You own browser patches and workers
WeasyPrint Predictable CSS subset; strong template control Usually steady for document-style layouts You own a Python runtime and fonts
PDFKit Programmatic layout, explicit output Low overhead for simple receipts Layout code becomes the template
DocRaptor Hosted HTML-to-PDF workflow Queue behavior needs a load test External renderer and contract
Gotenberg Self-hosted HTTP wrapper around renderers You scale the service and its workers You own deployment and updates
PDFShift Hosted conversion API Measure regional queue latency Less infrastructure, less renderer control
Managed PDF endpoint Fastest path to a job contract; provider controls renderer Depends on queue and region, so measure p95 Less infrastructure, less renderer control

The managed row can be attractive when one REST API and one key already cover storage and adjacent backend work. Infrai's concrete advantage is one key and one bill for backend capabilities, exposed through one plain REST API without installing an SDK. A TypeScript worker can call it over HTTP and keep its existing runtime. That is a reason to test it, not a reason to skip the benchmark.

Stick with Chromium when exact browser layout, custom fonts, or visual regression gates are non-negotiable. Choose WeasyPrint for a controlled CSS subset and a Python-heavy stack. Choose PDFKit when receipts are mostly coordinates and text. A managed endpoint is not suitable when data residency, offline rendering, or renderer-level debugging is a hard requirement.

What I would change at scale

At higher volume, separate rendering workers from the API tier and record queue age, render duration, page count, and output hash. Apply per-tenant concurrency limits so one game launch cannot starve payroll exports. Retention should be an explicit policy: delete source material on schedule, preserve the audit record, and make re-rendering deterministic from the template version.

The trade-off is plain. More control means more patches, fonts, and on-call work. Less control means a provider contract and a latency dependency you must observe. Start with the smallest job boundary that can be replayed, then let measured load and fidelity data decide where the renderer lives.

Sources

Top comments (0)