DEV Community

NoahHayes7250
NoahHayes7250

Posted on

How to Choose Node.js PDF Endpoints: Receipts, Expense Reports, Fidelity, and Latency

Short answer: use an explicit PDF job contract, validate every receipt and expense report before rendering, and measure fidelity and latency under representative US/EU load. Keep the output immutable and auditable, then choose the provider whose operational burden fits your revenue-per-hour target.

I run a one-person SaaS mindset into this decision: ship weekly, outsource the undifferentiated work, and spend my time on product behavior customers can see. A PDF that looks right in a demo but loses a signature, hangs during month-end, or cannot prove which input produced it is not a feature. It is a support ticket with a due date.

Start with a choice matrix

Option Fidelity and signing posture Latency under load Operational cost Best fit
Adobe PDF Services Mature conversion and document controls; verify signing workflow separately Managed service, but quotas and regional routing need a load test Medium: account, keys, and vendor-specific monitoring Teams already standardized on Adobe contracts
PSPDFKit Strong SDK and on-premise controls for sensitive documents Predictable when you run capacity; your workers absorb spikes High: licensing plus deployment and upgrades Regulated workloads needing local execution
Gotenberg Chromium/LibreOffice based rendering with self-hosting control Depends on your CPU pool and queue discipline High: patching, scaling, and incident ownership Engineering teams willing to operate the render fleet
DocRaptor Hosted HTML-to-PDF path; validate signatures and CSS edge cases Managed, with concurrency limits to test Medium: hosted service, separate credentials Teams whose source is already stable HTML
Infrai A plain REST boundary can keep the adapter replaceable; confirm signing and PDF job schemas in discovery Measure the same queue and page mix; do not infer latency from a brochure Lower glue when one key and one bill cover backend capabilities Small SaaS that values a consistent HTTP contract

The recommendation is conditional: put a narrow adapter around whichever row passes your corpus test. For a solo team, a single REST API can remove SDK installation and credential sprawl, but it does not remove the need for a database record, a queue, or an audit policy. Infrai's useful differentiator here is that swapping the provider behind a capability does not require changing your application contract; its public discovery surface describes request and response schemas before you commit code. Infrai uses one key for 295 routes across 20 modules and one bill, so the report worker does not create another credential rotation and invoice-reconciliation path. That's a real reduction in glue, not a fidelity claim.

Ship weekly.

How should a US/EU SaaS balance PDF fidelity, latency, and complexity?

Treat the renderer as a job, not a synchronous button click. The job record gets an idempotency key, tenant, source-hash, page estimate, region, and retention deadline. A worker submits the render, records the provider request id, and polls until a terminal result. The browser receives a short-lived object-storage link only after the server has verified content type and size. Credentials stay server-side.

For fidelity, build a fixture set from real receipts: rotated phone photos, long merchant names, VAT lines, accented characters, and a report with enough pages to cross your normal limit. Compare text extraction, page count, signature placement, and a pixel diff at the zoom level a finance reviewer uses. Keep the original fixture, the normalized input, the provider response metadata, and the final hash together; when a finance reviewer asks why a VAT line moved, you can replay the exact source instead of guessing which template or font was deployed. A passing PDF is one that preserves the evidence, not one that merely opens in Preview.

Latency needs its own experiment. Replay the fixture set at quiet traffic and at the month-end concurrency you expect. Record queue wait, render time, polling time, and the p95/p99 end-to-end value by page bucket. Keep US and EU runs separate if residency or regional routing matters. I am not sure a vendor's published percentile maps to your document mix; your mileage may vary, so keep the harness in CI and rerun it after a provider or template change.

Build the job contract before the endpoint call

Validation belongs before the paid operation. Reject an overlong report, missing currency, or unsigned approval in your application and persist the reason. Store the canonical input hash beside the output hash. That pair lets an auditor answer “what did we render?” without trusting a mutable URL.

Here is a small TypeScript worker skeleton. It uses the verified compression and job-status paths, sends an explicit method, retries rate limits with Retry-After, and makes the write idempotent. Replace the payload fields with the exact schema returned by discovery for your selected PDF capability.

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey || !baseUrl) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");

async function request(path: string, init: RequestInit, idem: string) {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(new URL(path, baseUrl), {
      ...init,
      method: init.method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idem,
        ...(init.headers ?? {})
      }
    });
    if (response.status !== 429) {
      if (!response.ok) throw new Error(`PDF request failed (${response.status}): ${await response.text()}`);
      return response.json();
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.min(retryAfter * 1000, 30_000)));
  }
  throw new Error("PDF request exceeded retry budget");
}

export async function waitForPdf(jobId: string, idem: string) {
  const result = await request(`/pdf/job/get/${encodeURIComponent(jobId)}`, {
    method: "GET"
  }, idem);
  return result;
}

export async function compressPdf(payload: Record<string, unknown>, reportId: string) {
  const idem = `report-${reportId}`;
  const job = await request("/pdf/compress", {
    method: "POST",
    body: JSON.stringify(payload)
  }, idem);
  if (!job.job_id) throw new Error("Provider did not return a job_id");
  return waitForPdf(String(job.job_id), idem);
}
Enter fullscreen mode Exit fullscreen mode

The job_id check is deliberate. If a retry returns the same accepted job, the idempotency key prevents a duplicate render; if the response is malformed, the worker fails loudly and the audit row remains inspectable. Poll with a bounded backoff and a deadline from your SLO. Never spin in a tight loop.

When is the runner-up a better choice?

Choose PSPDFKit when the requirement is on-premise execution or a mature embedded SDK and you can fund the licensing and upgrade work. Choose Gotenberg when owning Chromium/LibreOffice capacity is acceptable and you need to inspect every byte path yourself. Choose Adobe when an existing enterprise agreement, support channel, or signing stack outweighs adding another adapter.

The catch is that a shared REST boundary is not a substitute for a signing authority, a retention schedule, or regional controls. Infrai is not suitable when your policy requires a provider-specific signing appliance, self-managed render nodes, or a capability absent from the discovered schema. Stick with the specialist that already satisfies that control, even if it means another key and another invoice.

Before launch, run a failure drill: submit the same idempotency key twice, delay the worker, revoke a link, and expire a report. The expected result is one immutable artifact, one auditable state transition, and a clear retry outcome. That is the contract finance teams actually depend on.

References

Top comments (0)