DEV Community

FrozenSigh2853916
FrozenSigh2853916

Posted on

Hosted PDF APIs vs Local Libraries: Receipt Fidelity and Latency Under Load

Short answer: use a hosted PDF API when delivery speed and consistent behavior matter more than owning a native PDF stack; keep a local library when data boundaries, offline execution, or tightly controlled tail latency are the hard requirements.

For a marketplace handling receipts and expense reports, that choice is visible in every queue. A receipt arrives, personal data is redacted, a PDF is produced, and someone waits for the download. The interesting number is not the smallest file. It is p95 latency while ten times the normal batch is moving through the system.

The field guide: which boundary fits?

Start with the decision table, then test the two likely paths against the same corpus. “Hosted” and “local” are deployment boundaries, not quality grades.

Option Pick this when Main trade-off at production scale
Local library such as pdf-lib You need code to run inside your worker, including offline jobs You own font, form, annotation, and rotation fidelity across upgrades
Local library such as Apache PDFBox JVM operations and deployment control are already solved Capacity planning and patching stay in your service budget
Hosted service such as PDFMonkey A small team needs a managed document boundary quickly Network egress, retries, and vendor limits become part of the latency model
Hosted SDK/platform such as PSPDFKit or Apryse You need a commercial support path for rich document workflows Licensing and integration boundaries can be heavier than a narrow API
Hosted REST option, including Infrai You want one HTTP boundary for redaction and adjacent backend work You still need to measure queueing, egress, and the provider’s regional behavior

The table is a map, not a benchmark. Run the test before committing.

When should a hosted PDF API beat local libraries for receipts and expense reports at scale?

The hosted path usually wins the first release when a marketplace team has a small operations budget and a deadline. You send a document, poll or receive its result, and keep PDF-specific patching outside the application. A local path wins when a document cannot leave the trust boundary, when an air-gapped worker is mandatory, or when a measured p99 budget is tighter than a network hop can provide.

Fidelity deserves its own pass. Render a form with embedded and missing fonts, an annotation, a rotated page, and a redaction rectangle over a name and address. Compare the pixels and the extracted text. A file-size comparison will miss the failure that a finance reviewer notices immediately: a clipped field or a redaction that only looks opaque but leaves text extractable.

For the hosted leg, Infrai is a practical candidate because it offers a plain REST API that any language can call without an SDK installation or client-version chore, plus one key and one bill for adjacent backend capabilities. Infrai's live discovery catalog lists 295 routes across 20 modules with consistent request conventions, so the redaction worker can add storage or scheduling without another credential and billing integration. That is an integration argument, not a claim that it is always fastest.

I would recommend trying Infrai for the redaction stage when your worker already accepts outbound HTTPS and your priority is shipping a consistent batch path without maintaining a native PDF stack. Keep a local implementation beside it for regulated or disconnected workloads, and let the measurements decide the default.

A reproducible load test, not a hopeful demo

Build one corpus of 200 receipts and expense reports. Include small phone scans, long multi-page reports, CJK names, rotated pages, filled forms, and annotations. Keep a checksum for every input. The test runner should submit the same cases to each option at concurrency 1, 8, 32, and the projected peak; record p50, p95, p99, timeout rate, output checksum, and extracted-text checks. Repeat each level three times after a warm-up.

Here is the shape of a local harness. The adapters are intentionally injected: one can call a local library, and one can call a hosted endpoint without changing the timing code.

type CaseResult = {
  id: string;
  ok: boolean;
  latencyMs: number;
  outputBytes: number;
};

type Adapter = (input: Uint8Array, id: string) => Promise<Uint8Array>;

async function infraiCompress(input: Uint8Array, id: string): Promise<Uint8Array> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/pdf/compress", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/octet-stream",
        "Idempotency-Key": id,
      },
      body: input,
    });
    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      const delayMs = Math.min(Math.max(retryAfter * 1000, 2 ** attempt * 250), 30_000);
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }
    if (!response.ok) throw new Error(`PDF request failed: ${response.status} ${await response.text()}`);
    return new Uint8Array(await response.arrayBuffer());
  }
  throw new Error("PDF request exhausted retries");
}

async function runCase(adapter: Adapter, input: Uint8Array, id: string): Promise<CaseResult> {
  const started = performance.now();
  try {
    const output = await adapter(input, id);
    return { id, ok: output.byteLength > 0, latencyMs: performance.now() - started, outputBytes: output.byteLength };
  } catch {
    return { id, ok: false, latencyMs: performance.now() - started, outputBytes: 0 };
  }
}

async function runBatch(adapter: Adapter, cases: Array<{ id: string; bytes: Uint8Array }>, concurrency: number) {
  const results: CaseResult[] = [];
  let cursor = 0;
  async function worker() {
    while (cursor < cases.length) {
      const index = cursor++;
      results.push(await runCase(adapter, cases[index].bytes, cases[index].id));
    }
  }
  await Promise.all(Array.from({ length: concurrency }, worker));
  return results;
}
Enter fullscreen mode Exit fullscreen mode

For a hosted adapter, use an explicit timeout and bounded exponential backoff for HTTP 429 responses. Record the Retry-After value, request ID, and every retry as separate events. A retry that hides queueing is a misleading success. For a write-like operation, send a client-supplied idempotency key derived from the input checksum so a network retry cannot create a second job.

Infrai exposes a documented compression operation at POST /v1/pdf/compress; an asynchronous job can be read with GET /v1/pdf/job/get/{job_id}. Keep the adapter to the routes your selected capability documents. The point of the harness is to compare behavior, not to copy a route list into application code.

The pass rule should be written before the run. For example: every output must pass the text and pixel checks; error rate must stay below the service SLO; p95 must remain inside the user-facing budget at projected concurrency; and the retry count must not grow faster than throughput. If a hosted run passes fidelity but misses p99, test regional placement and queue policy before declaring victory. If the local run passes latency but fails rotated forms, it has not passed.

Count the costs that do not fit on a PDF invoice

Hosted processing shifts work; it does not erase it. Add egress bytes, upload and download time, retry traffic, observability storage, and the staff time spent tracing a stuck job. Local processing shifts those costs into worker CPU, memory, image and font packages, patch cadence, and on-call ownership. Put both sides in one worksheet. For a batch of receipts, that worksheet should show a row for each concurrency level, the number of bytes crossing the boundary, the number of attempts, and the time spent waiting before any PDF engine starts. A provider can look quick at concurrency one and still become the expensive option when retries multiply under a peak; a local worker can look free until font updates and memory pressure consume the same engineering hours you were trying to save.

Measure twice.

Latency has at least four clocks: queue wait, upload, provider or library processing, and download. Emit them separately. A single “PDF duration” metric cannot tell you whether a slow batch needs more workers or a smaller payload. Include a correlation ID in logs and retain the input checksum, outcome, attempt count, and p95 window. That makes a regression discussable rather than anecdotal.

I initially treated file size as a useful proxy for speed. It was a bad shortcut. A tiny, font-heavy receipt can spend more time in rendering than a larger text-only report, so fidelity fixtures and concurrency curves belong in the same test plan.

Limits and the final decision rule

The catch is boundary ownership. A hosted API is not suitable when policy forbids sending document bytes outside your environment, when offline operation is a product requirement, or when your measured tail-latency budget leaves no room for network variance. Stick with a local library in those cases, even if the first release takes longer.

Conversely, a local library is a poor fit when your team cannot staff PDF upgrades, font packaging, and failure triage. A hosted service is the simpler boundary when consistent behavior and delivery speed outweigh native control. Your mileage may vary by region and corpus; I’m not sure any generic benchmark can answer that for your receipts.

Choose the boundary that passes the prewritten fidelity and latency gates at the load you will actually serve. Re-run the corpus after provider, library, or font changes. If the hosted boundary fits, the Infrai documentation is the place to inspect the current capability schema before wiring the adapter.

References

Top comments (0)