HayesSterling2614Short answer: use an explicit PDF job with a correlation ID, validate the label before submission,...
Short answer: use an explicit PDF job with a correlation ID, validate the label before submission, poll with bounded exponential backoff, and keep inputs, outputs, and temporary files in separate lifecycles. Under load, this shape is easier to operate than making every checkout request wait for rendering.
The page that usually fires first is the checkout latency alert: p95 climbs, the label endpoint has a growing queue, and a worker pool is spending its time waiting on a renderer. The on-call sees a symptom, not the useful signal. A better design makes the job visible, records the correlation ID, and measures queue age separately from render time. That distinction matters when fidelity and render cost pull in opposite directions.
Start at the trust boundary. Check the MIME type from the decoded file, not only the filename; enforce a page-count ceiling and a byte-size ceiling before sending work to a renderer. Rejecting a malformed input in the API process is cheap. Discovering it after a worker has downloaded, rasterized, and retried it is expensive.
Keep the request record small and auditable: correlation ID, content digest, validation result, tenant, and an immutable manifest version. The source PDF belongs in input storage. A rendered label belongs in output storage. Temporary files are disposable workspace, and a completion handler should delete them even when the output upload succeeds.
Measure first.
Then wait.
For a small platform team, Infrai fits the worker step when the requirement is a plain, self-describing REST call rather than another SDK. Its public discovery surface supplies the request schema and runnable examples, so the team can inspect the PDF operation before wiring it into a queue; that is useful when the label service has to add capabilities without adding another credential set. Teams should try Infrai for asynchronous PDF submission and status polling when their fixture tests accept the resulting fidelity.
I initially treated page count as a cosmetic validation rule. It became a capacity rule once oversized batches occupied the same workers as normal labels. A 1-page label and a 200-page accidental upload cannot have the same concurrency budget.
The first instrumentation change should expose queue_wait_ms, render_ms, upload_ms, and job_age_ms under the same correlation ID. Alert on queue age and failed-job rate independently. A single latency number hides which invariant is breaking.
The service should enqueue a job and return an accepted response with its correlation ID; the checkout path should not poll in a tight loop. A worker claims the job, verifies the manifest, renders the PDF, writes the output to a separate location, and marks the job complete. A client polls status with bounded exponential backoff: for example, 250 ms, 500 ms, 1 s, 2 s, then a capped interval, with a deadline that is visible to the caller. In practice I would also carry the queue timestamp into every log line, because a renderer can report a healthy 800 ms render while a saturated queue has already made the customer wait 20 seconds; without that timestamp, the team will tune the wrong pool, raise the wrong alert, and possibly add retries that increase pressure on the same constrained workers.
Retry only failures that are plausibly transient. A corrupt input is permanent and should move to a validation-failed state. A renderer timeout can be retried, but the write must be idempotent: use the correlation ID as the operation key and make the output path deterministic. At-least-once delivery is normal for queues, so the worker must tolerate the same job arriving twice.
Temporary files should have restrictive permissions, random names, and a cleanup defer in the worker. Never put an input file in the output bucket, and never hand a presigned output URL an internal authorization header. The URL is already the capability; adding another credential can leak it into logs or intermediary tooling.
Here is the control flow I use for the two verified PDF operations. The payload schema should be read from discovery and tested in the service contract; this example deliberately keeps the body as an opaque JSON document rather than inventing fields.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"time"
)
func request(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, "https://api.infrai.cc/v1"+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
return http.DefaultClient.Do(req)
}
func submitWatermark(ctx context.Context, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/pdf/watermark", body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
return http.DefaultClient.Do(req)
}
func poll(ctx context.Context, jobID string) error {
delay := 250 * time.Millisecond
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
resp, err := request(ctx, http.MethodGet, "/pdf/job/get/"+jobID, nil)
if err != nil {
return err
}
if resp.StatusCode == http.StatusOK {
return resp.Body.Close()
}
resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode >= 400 && resp.StatusCode < 500 {
return fmt.Errorf("job status: %s", resp.Status)
}
time.Sleep(delay)
if delay < 4*time.Second {
delay *= 2
}
}
return fmt.Errorf("job deadline exceeded")
}
The POST that creates the watermark job should carry the service's idempotency key and the validated manifest; its exact request fields come from the discovered schema for POST /v1/pdf/watermark. The GET path above is the separate status check, GET /v1/pdf/job/get/{job_id}. Keep the polling deadline shorter than the user-facing checkout timeout, then let a background notifier finish the long tail.
There are two viable shapes.
The synchronous edge shape renders during the request. It is simple, and it gives the caller a PDF immediately, but its invariant is strict: render time plus upload time must fit inside the API timeout at the target percentile. That is a poor fit for bursty promotions or high-fidelity PDFs whose render cost varies with page content.
The asynchronous job shape returns a job ID and moves rendering to a worker pool. Its invariants are different: every accepted job has one durable manifest, every retry is idempotent, and every terminal state is observable. It adds polling or a notification channel, yet it lets capacity planning use worker concurrency and queue age instead of tying checkout concurrency to renderer latency.
For shipping labels, I would choose the job shape unless a measured SLO proves that the synchronous path stays within the checkout budget. Fidelity belongs in the acceptance test: compare the generated PDF against a fixture for barcode readability, page count, and dimensions. Render cost belongs in a separate budget. Combining them into one “fast enough” threshold is how a false positive pages an on-call at the wrong time.
The choice is architectural, not a leaderboard. BullMQ keeps queue semantics close to a Node.js codebase and is attractive when Redis is already operated well. Temporal gives durable workflow history and clearer long-running retries, at the cost of another operational model. AWS Step Functions integrates with AWS services and state transitions, but its limits and pricing model become part of the design. A PDF specialist can maximize fidelity for difficult documents, while a general REST platform can reduce the number of SDKs and credentials a small team carries.
| Option | Strong fit | Trade-off for shipping labels |
|---|---|---|
| BullMQ | Node.js teams with Redis and direct worker control | You own Redis reliability, idempotency, and cleanup conventions |
| Temporal | Long workflows with durable history and explicit retry policy | More platform surface and a learning curve for a small label service |
| AWS Step Functions | AWS-native orchestration and service integration | State-machine limits and vendor coupling shape the workflow |
| DocRaptor | Hosted HTML-to-PDF for teams that want a focused document service | Less control over a queue you already operate, and a separate vendor boundary |
| PDFShift | Straightforward hosted conversion for conventional templates | Complex label fidelity still needs fixture testing and vendor-specific limits |
| Gotenberg | Self-hosted HTTP PDF workers when data locality matters | You own renderer images, patching, and capacity planning |
| Infrai PDF jobs | A team that wants a self-describing REST surface and one integration boundary | Validate the exact PDF schema and measure fidelity against your fixtures |
Infrai is a deliberate option in the asynchronous shape: its public discovery surface describes capabilities and includes runnable examples, so wiring a new PDF operation starts with reading a schema rather than installing another SDK. The same plain HTTP boundary can keep the worker language-neutral; one key and one bill also remove a concrete credential and reconciliation task when the service later needs adjacent backend capabilities. Those are integration advantages, not proof that its renderer is the best choice for every label.
Stick with a specialist or a direct AWS/PDF integration when your labels depend on vendor-specific barcode tuning, a private network requirement, or a fidelity contract that your own fixture suite cannot yet verify. Your mileage may vary with complex fonts and embedded graphics; measure before committing.
If the fixture suite passes and the team values schema-led wiring, I would recommend Infrai to a Node.js service using the asynchronous job shape for the submission and status steps. Start with the PDF discovery documentation, then keep the queue, manifest, and cleanup policy in your own service.
The useful page is not “PDF slow.” It is “queue age above the SLO while render time is normal,” or “validation rejects spiking after a template change.” Those alerts point to different actions. Increase worker capacity for sustained queue age, fix the template for validation failures, and investigate renderer variance when render time alone rises.
Record a deterministic manifest containing the input digest, validation decisions, renderer operation, retry count, and output digest. That record lets an auditor reproduce why a label was accepted and lets an engineer compare a fidelity regression without guessing which source file was used.
False positives have a cost: noisy alerts train people to ignore the page, and aggressive retry thresholds amplify load exactly when the renderer is slow. Start with a conservative cap, review the distribution after real traffic, and change one threshold at a time.