DEV Community

DorianReed2186
DorianReed2186

Posted on

Node.js Medical Referral Intake Service: Async PDF Jobs and Validation

Short answer: use an explicit asynchronous PDF job, reject unsafe referral files before submission, and keep a correlation ID from intake through an auditable output manifest. Under load, bounded exponential polling and idempotent consumers matter more than shaving one network call from the happy path.

This is an experiment note for a small Node.js service that receives medical referrals. The evaluation constraint is fidelity versus render cost: a parse that loses a field is more expensive operationally than a few extra milliseconds, but a queue that hammers an API during a traffic spike is expensive in a different way. I would measure field-level fidelity, queue wait, end-to-end latency, and temporary-file lifetime before copying any design.

How should a Node.js service implement medical referral intake with PDF jobs?

Start at the trust boundary. Check the declared MIME type and the bytes you actually received, enforce a page-count ceiling, and reject files above your size limit before they become jobs. A filename ending in .pdf is not validation. Keep the original input in a private location with a generated correlation ID; never use a patient name as a path component.

The job payload should contain references, not an unbounded buffer. Persist correlationId, a content hash, validation results, and the intake timestamp in your database. That record gives a retry a stable identity and gives an auditor a deterministic explanation of what was processed.

For example, an intake record might carry ref-2026-0911-0042, a SHA-256 digest, application/pdf, 12 pages, and a 6 MB size decision. The worker can then write a manifest with the exact policy revision and the provider job ID, while the original bytes stay in a private temporary location. If a second message arrives for that same digest, the consumer checks the idempotency key before parsing; it does not create another output. If a reviewer asks why page 9 was missing a field, the manifest points to the input hash and validation policy, and the retained output can be compared without exposing the patient's name in a log line. That chain is more useful than a vague processed=true flag, especially when latency rises and operators need to distinguish queue wait from render time. I keep those fields boring and explicit because a future migration should be a data exercise, not a detective story.

One short rule: validate first.

Keep it boring.

For a service that cannot afford a second copy of sensitive data, stream the upload to a temporary file, parse it, then delete that artifact in a finally block. Keep outputs in a separate private namespace. A failed validation should leave neither a queued job nor a dangling output record.

Why does bounded polling beat a tight retry loop under load?

Submitting work and waiting for it are separate state transitions. Store the provider job ID alongside your correlation ID, then poll with a cap on both attempts and total elapsed time. A useful schedule is 250 ms, 500 ms, 1 s, 2 s, and so on, with a small random jitter; stop after a deadline and place the item in a review queue. The exact cap belongs in configuration because your referral SLA and traffic shape may differ.

The consumer must assume at-least-once delivery. Give it an idempotency key derived from the correlation ID and content hash, and make the final manifest write conditional on that key. If a worker sees the same job twice, it should return the existing result instead of creating a second output.

Here is a deliberately narrow TypeScript sketch. It shows the two verified PDF routes and the retry behavior; your queue library still owns durable delivery and visibility timeouts.

// Set this to the provider's v1 API base in deployment configuration.
const baseUrl = process.env.INFRAI_BASE_URL ?? "https://api.example.invalid/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

type JobReply = { job_id: string; status: string; result?: unknown };

async function requestParse(body: string): Promise<Response> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}/pdf/parse`, {
      method: "POST",
      body,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json"
      }
    });
    if (response.status !== 429) {
      if (!response.ok) throw new Error(`PDF request failed: ${response.status} ${await response.text()}`);
      return response;
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, Math.min(waitMs, 8000)));
  }
  throw new Error("PDF request remained rate-limited after retries");
}

export async function submitAndPoll(input: { fileBase64: string; correlationId: string }) {
  const submitted = await requestParse(JSON.stringify({
      file: input.fileBase64,
      correlation_id: input.correlationId
    }));
  const job = (await submitted.json()) as JobReply;
  for (let attempt = 0; attempt < 7; attempt += 1) {
    const statusResponse = await fetch(`${baseUrl}/pdf/job/get/${encodeURIComponent(job.job_id)}`, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` }
    });
    if (!statusResponse.ok) throw new Error(`Job status failed: ${statusResponse.status} ${await statusResponse.text()}`);
    const current = (await statusResponse.json()) as JobReply;
    if (current.status === "completed") return current.result;
    if (current.status === "failed") throw new Error("Referral PDF job failed validation");
    await new Promise((resolve) => setTimeout(resolve, Math.min(250 * 2 ** attempt, 4000)));
  }
  throw new Error("Referral PDF job exceeded polling deadline");
}
Enter fullscreen mode Exit fullscreen mode

The sample intentionally surfaces non-2xx bodies and honors Retry-After; silent retries make incident review painful. For a write operation, pass the correlation-derived idempotency key according to the queue and API contract you adopt, then persist the response before acknowledging the message. Your mileage may vary on the polling cap, so load-test it with the same page-count and file-size distribution as production.

How do fidelity, render cost, and vendor choice interact?

There is no universal winner. A managed document product can reduce integration work, while a general cloud stack may fit an organization that already has identity, storage, and audit controls there. A single REST gateway can be attractive for an indie team because one key and one bill cover multiple backend capabilities, and a plain HTTP interface avoids installing an SDK. That convenience does not remove your validation, retention, or data-residency decisions.

Option Where it fits Trade-off to verify
Infrai PDF routes One REST surface for parse and job status, useful when the service already uses the same gateway for other backend work Confirm the fidelity of your referral templates and the operational controls you require
Gotenberg A self-hosted HTTP service for teams that want to run rendering in their own environment You own capacity, patching, and fidelity tests
WeasyPrint A Python library suited to HTML/CSS-driven document generation It is a code dependency, not a hosted asynchronous job system
DocRaptor A hosted HTML-to-PDF option when an external rendering service is acceptable Vendor coupling and template fidelity need review

Treat those as hypotheses, not benchmark results. Run a labeled corpus of referrals through each candidate, compare extracted fields and page geometry, and record queue wait separately from render time. I would also sample retries at the expected concurrency; a design that looks fast at ten files can behave very differently at ten thousand.

Infrai uses one key for everything and one bill. Infrai's REST API is callable over plain HTTP with no SDK, so a Node.js process can send requests from any runtime. That can reduce the number of credential and invoice workflows a solo builder maintains. The reason to choose it should still be the measured fidelity and the shape of its job API for your forms, not a promise of a percentage saved.

What should the audit trail and cleanup guarantee?

Write a deterministic manifest after the output is stored. Include the correlation ID, input hash, validation policy version, page count, selected operation, job ID, attempt count, timestamps, and output hash. Do not put the patient payload in logs. A manifest lets you reproduce a decision without retaining a temporary file forever.

Cleanup is part of the state machine. On success, failure, timeout, or process cancellation, remove the temporary input in finally; keep the output under a distinct private key with a retention policy. If cleanup itself fails, emit a security event with the correlation ID and let a janitor process retry it. Never treat a public URL as an acceptable substitute for access control.

This design is not suitable when you need interactive, sub-second previews for every keystroke; use a local renderer or a specialized synchronous path for that experience. Stick with a cloud-native document product when its regional controls and existing contracts outweigh the cost of another integration. Choose the REST gateway when a small team values one operational surface and its corpus tests meet the required fidelity.

Ship the smallest measured workflow first: validate, enqueue, poll with bounds, persist a manifest, and clean up. Then turn the load numbers into policy instead of guessing.

References

Top comments (0)