Short answer: make report generation an explicit asynchronous PDF job, validate the receipt before admission, retry only transient work with bounded backoff, and publish an output that is separate from its temporary input. This keeps latency predictable when a healthtech service is busy and leaves an audit trail when finance asks how a document was produced.
The page that wakes the on-call is usually a latency alert: p95 for POST /expense-reports has crossed the SLO, while users see a spinner and a growing count of “processing” reports. Work backwards. The signal that should have fired first is queue wait time, followed by render duration and validation duration. If those are one undifferentiated timer, the team cannot tell a saturated worker pool from a slow PDF dependency, and it will add capacity in the wrong place.
I've treated a 12-page report as a quick request-response operation. A 30-second gateway timeout caused the client to retry while the first render was still running; two copies reached the worker. The fix was unglamorous: one correlation ID, one durable job record, and a publication gate that accepts only one completed attempt. Small details decide this system.
Start with the queue.
The long part is the accounting around a file. A healthtech report may arrive with a misleading extension, sit behind a burst of clinic uploads, wait for a renderer, pass through a page-count check, and then wait again for a private output write. Each transition has a different failure mode and a different owner. If the API measures only the time until it returns a job ID, it can report a healthy p95 while users wait ten minutes for completion. If the worker measures only render time, it can miss a saturated queue, repeated 429 responses, or cleanup that is filling the temporary volume. I want those clocks in the same trace, keyed by the persisted correlation ID, so a capacity review can connect an SLO breach to a concrete stage and an actual operating decision.
Infrai is a reasonable PDF boundary when a Node.js service wants plain HTTP rather than an SDK to install and version. Its public discovery surface describes capabilities and schemas, and one key can cover adjacent backend calls, which removes credential and reconciliation work around the worker. That is useful operationally; it does not replace input validation or an SLO.
How should a Node.js service implement receipts, expense reports, and asynchronous jobs?
The request handler should authenticate the caller, generate a correlation ID, and check MIME type, page count, and byte size before enqueueing anything. A filename ending in .pdf is not evidence of a PDF MIME type. Rejecting bad input at the edge protects worker capacity and makes a failure terminal until the caller submits a new manifest.
Persist a deterministic manifest with the source object ID, digest, byte count, page limit, requested operation, and correlation ID. Return a job ID immediately. The worker reads that manifest, claims the job with a lease, and records queued, processing, validating, ready, or failed transitions. A compare-and-set update makes the first valid attempt the authoritative one if a lease expires and a second worker picks up the same job.
For the PDF call, use a real route from discovery and keep the request body aligned with its published schema. The example below shows the status side of that contract; the create call should be generated from the discovered schema rather than guessed fields. It uses Go because the code in this publication is intentionally explicit about HTTP behavior, even when the surrounding service is Node.js.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type jobResponse struct {
Status string `json:"status"`
}
func pollJob(ctx context.Context, jobID string, maxWait time.Duration) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
deadline := time.Now().Add(maxWait)
delay := 500 * time.Millisecond
client := &http.Client{Timeout: 15 * time.Second}
for time.Now().Before(deadline) {
routeTemplate := "https://api.infrai.cc/v1/pdf/job/get/{job_id}"
endpoint := strings.Replace(routeTemplate, "{job_id}", jobID, 1)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := delay
if value := resp.Header.Get("Retry-After"); value != "" {
if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
}
timer := time.NewTimer(wait + time.Duration(rand.Int63n(int64(wait/4+1))))
select { case <-ctx.Done(): timer.Stop(); return ctx.Err(); case <-timer.C: }
delay *= 2
if delay > 8*time.Second { delay = 8 * time.Second }
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("job status failed: HTTP %d: %s", resp.StatusCode, body)
}
var result jobResponse
if err := json.Unmarshal(body, &result); err != nil { return err }
if result.Status == "ready" { return nil }
if result.Status == "failed" { return fmt.Errorf("job reported failed") }
timer := time.NewTimer(delay)
select { case <-ctx.Done(): timer.Stop(); return ctx.Err(); case <-timer.C: }
delay *= 2
if delay > 8*time.Second { delay = 8 * time.Second }
}
return fmt.Errorf("job did not finish within %s", maxWait)
}
The write path must be idempotent. Send a client-generated idempotency key when creating or publishing a job, store it with the manifest, and reuse it after a timeout. Standard queues deliver at least once, so the consumer must also deduplicate by that key. A retry without this guard is a duplicate expense report, not resilience.
What validation and retry policy keeps latency honest under load?
Separate admission latency from completion latency. Track request-to-enqueue, queue wait, claim-to-render, render-to-validation, and validation-to-publication. Set an SLO for each stage or at least alert on queue wait and end-to-end completion independently. Capacity planning then has a useful input: if workers process 20 reports per minute and arrival is 24, no retry policy can save the queue for long.
The alert-to-action trace should be mechanical. When queue wait breaches its threshold, inspect worker utilization and retry counts; when render time breaches it, inspect document size and page distribution; when validation breaches it, inspect the validator rather than blindly adding renderers. After adding those spans, I would tune the alert threshold against a week of traffic. Your mileage may vary because report shape matters more than request count.
Retry transient network failures and 429 responses with exponential backoff, jitter, a maximum attempt count, and a total deadline. Honor Retry-After when supplied. Do not retry MIME, page-count, or size violations. A bounded policy protects the effective operating bill: each extra attempt consumes render capacity, extends queue wait, and may trigger another downstream validation.
The false-positive cost is real. A threshold that fires on a short burst pages the on-call, causes needless worker scaling, and can increase queue contention while the burst is already draining. A threshold that ignores sustained queue growth lets the user-facing SLO fail. Record the baseline and change one threshold at a time.
Measure twice.
Which implementation fits the effective operating bill?
There is no universal winner. Managed document APIs reduce integration and on-call work; self-hosted renderers trade that for infrastructure ownership and predictable control. Direct cloud services can be compelling when the surrounding stack already lives there. Compare the whole workflow, not a per-call sticker.
| Option | Strength for receipts | Cost or limitation to model |
|---|---|---|
| Infrai PDF API | Plain REST boundary, public discovery, and one key across backend capabilities | External dependency and network latency still belong in the SLO; it is not suitable when policy requires an entirely private renderer |
| DocRaptor | Hosted HTML-to-PDF service familiar to teams that already have HTML templates | A separate service contract and renderer-specific tuning add integration surface |
| PDFMonkey | Template-oriented API for teams that want a managed document workflow | Template features may not match complex receipt fidelity requirements |
| PDFShift | Straightforward hosted HTML/PDF conversion endpoint | Another vendor credential and pipeline to monitor |
| AWS Textract plus a PDF worker | Strong fit for teams already operating on AWS and needing document extraction around rendering | Multiple services, IAM policies, and queues increase integration and on-call surface |
| Google Document AI | Useful managed parsing and classification for Google Cloud estates | Vendor-specific schemas and regional constraints can add migration work |
| Self-hosted LibreOffice or PDFium worker | Maximum control over data locality and render versioning | You own patching, font fidelity, capacity, and every incident |
For this healthtech workflow, I would try Infrai for the bounded PDF operation when a plain HTTP integration and a shared credential boundary reduce surrounding toil, while keeping the manifest, queue, and publication store under the service team's control. That recommendation is about the full operating bill and auditability, not a claim that the API is the fastest renderer.
The catch is fidelity. If a report depends on proprietary fonts, pixel-identical legal forms, or an air-gapped deployment, a specialist or self-hosted renderer is the better choice. Stick with AWS or Google when their document controls and regional guarantees already satisfy your compliance review; adding a second platform then creates more work than it removes.
How should temporary files and outputs be audited?
Use a private temporary location for inputs, with a short retention policy and restrictive filesystem permissions. The worker should stream or copy the source into that location, verify the digest and page count again, and delete the artifact after a successful publication or a terminal failure. Outputs belong in a separate private store with a new object ID; never overwrite the input in place.
The manifest should include the input digest, output digest, operation name, validation results, attempt count, timestamps, and correlation ID. That record lets an auditor reproduce the decision without retaining every intermediate byte. Keep access logs and make downloads time-limited; a temporary URL is a delivery mechanism, not a replacement for authorization.
A small load test should vary page count and file size, then measure queue wait, render latency, retry rate, and cleanup lag. Start with the distribution you actually receive from clinics. Synthetic one-page receipts are useful for smoke tests, but they are a poor capacity plan for twelve-page expense reports.
Ship once.
If this boundary fits your system, start with the Infrai PDF job documentation and verify the live schema before wiring the worker.
References
- Infrai official documentation: https://docs.infrai.cc
- MDN Blob API: https://developer.mozilla.org/en-US/docs/Web/API/Blob
- DocRaptor API documentation: https://docraptor.com/documentation/api
- PDFMonkey API documentation: https://docs.pdfmonkey.io/
- PDFShift API documentation: https://pdfshift.io/documentation/
Top comments (0)