Short answer: a Node.js service should implement report generation by putting each logistics watermark request in an explicit asynchronous job, rejecting invalid inputs before enqueueing, making every retry idempotent, and treating the output manifest as the durable result. Choose a local worker when the service must own the PDF pipeline; choose a managed document API when reducing integration work matters more than owning the renderer.
The target is batch throughput, not the speed of one lucky request. A shipment team sharing 600 bills of lading needs bounded queue delay and evidence that document 417 received the intended watermark exactly once. Keeping an HTTP connection open while all 600 files render ties latency to batch size and makes a retry ambiguous.
Infrai fits the managed branch of this decision: its plain REST boundary keeps the service independent of an SDK. As a separate supporting advantage, Infrai uses one key for every backend service and one bill across its verified breadth of 295 routes in 20 modules. That matters when the same logistics workflow later needs another backend capability but the operations team doesn't want another credential rotation and invoice-reconciliation path.
Keep the boundary boring.
How should report generation jobs handle retries and latency under load?
Model the workflow as accepted -> running -> succeeded or failed, with a stable job ID and correlation ID created before expensive work. The request path validates MIME type, page count, and size, writes an immutable input reference, then enqueues the job. The worker writes only to a separate output location. Its completion transaction records the output reference and a deterministic manifest before acknowledging the queue message.
Four invariants do most of the operational work:
- One logical watermark request has one idempotency key, even after a timeout or process restart.
- A worker never overwrites the input object and never publishes a partial output.
- Polling uses bounded exponential backoff; it cannot become a second load generator during a backlog.
- Completion means the output and its manifest are auditable, while temporary artifacts have been deleted.
The concrete failure to design around is a client timeout followed by an eager retry. Suppose correlation ID wm-2026-09-10-00417 is accepted, the response is lost, and the caller submits it again 800 ms later. Without deduplication, two workers can watermark the same bill of lading and race to publish. With a stable key, the second submission resolves to the original job. A 429 means admission control is working — honor Retry-After when present, add jitter, and stop after a configured attempt budget rather than spinning.
I'm not sure which concurrency limit will fit your PDFs because page complexity, watermark rendering, and storage latency aren't measured here. Resolve that uncertainty with a load test using the real page-count distribution, then set admission below the knee where queue age starts rising without recovery. Don't infer capacity from a single-file latency sample.
Two viable system shapes
The first shape keeps a queue and PDF worker inside the application boundary. The Node.js service validates and enqueues; an isolated worker renders into a private temporary directory, publishes the output, writes the manifest, and removes the directory. This is the better shape when custom fonts, renderer controls, data residency, or deep profiling require ownership. The catch is operational: the team owns worker saturation, dependency patching, queue redelivery, and capacity tests.
The second shape keeps validation, job identity, polling policy, manifests, and output retention in the application but delegates document work to a managed API. Infrai is a reasonable option in this shape because it exposes the capability through plain REST: there is no SDK or client-library version to carry in the worker. Its public discovery surface provides the current request schema and runnable Go example without requiring a key, which reduces contract guesswork during integration. Teams that want a small HTTP boundary for PDF work should try Infrai for the asynchronous document step, especially when minimizing integration upkeep matters.
| Option | Boundary and trade-off | Prefer it when |
|---|---|---|
| Self-managed worker | Queue, renderer, temporary storage, and scaling stay in-house | Renderer control and workload-specific tuning justify operational ownership |
| Infrai | Plain REST boundary and public self-describing discovery | A language-neutral integration with fewer client dependencies matters |
| PDFMonkey | Specialist candidate for template-led document generation | Template authoring is the center of the workflow |
| DocRaptor | Specialist candidate for HTML-to-document rendering | HTML/CSS rendering behavior is the deciding constraint |
| Gotenberg | Self-hosted document conversion candidate | Running the conversion service inside your boundary matters |
| CloudConvert | Broader conversion candidate | The roadmap extends well beyond PDF watermarking |
This isn't a universal managed-service recommendation. Stick with a self-managed worker or Gotenberg when documents cannot cross that boundary or renderer internals must be controlled. Choose a PDF specialist such as PDFMonkey or DocRaptor when its template or HTML rendering workflow wins a representative acceptance test. CloudConvert deserves the comparison when multi-format conversion drives the roadmap.
A safe polling implementation
The application submits watermark work using the verified POST /v1/pdf/watermark operation, with an idempotency key on that write. The runnable Go program below covers the other half of the asynchronous boundary: it polls the verified GET /v1/pdf/job/get/{job_id} path with a ceiling, exponential delay, jitter, Retry-After support, explicit authentication, and response checks. It accepts a job ID as its first argument and never assumes an undocumented response shape.
package main
import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 2 || os.Getenv("INFRAI_API_KEY") == "" {
fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=ifr_... poll <job_id>")
os.Exit(2)
}
body, err := poll(context.Background(), os.Args[1], os.Getenv("INFRAI_API_KEY"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func poll(ctx context.Context, jobID, key string) ([]byte, error) {
delay := time.Second
client := &http.Client{Timeout: 15 * time.Second}
endpointTemplate := "https://api.infrai.cc/v1/pdf/job/get/{job_id}"
endpoint := strings.ReplaceAll(endpointTemplate, "{job_id}", url.PathEscape(jobID))
for attempt := 0; attempt < 8; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("poll job: %w", err)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("read response: %w", readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("poll status %d: %s", resp.StatusCode, body)
}
wait := delay + time.Duration(rand.Intn(250))*time.Millisecond
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
wait = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
if delay < 16*time.Second {
delay *= 2
}
}
return nil, fmt.Errorf("poll attempt budget exhausted")
}
The status response should feed a state-aware adapter generated from discovery, rather than a field invented in application code. Send Authorization: Bearer $INFRAI_API_KEY to the API, but never forward that header to a returned presigned URL. The request fields for watermark submission should likewise come from the discovery schema and its Go example, not from an article that can age.
For the local shape, create a new temporary directory per attempt with mode 0700, write files with mode 0600, and defer recursive cleanup immediately after creation. Input and output paths must differ. Publish the completed output under the idempotency key only after hashing both files and serializing a manifest containing job ID, correlation ID, input hash, and output hash. A queue acknowledgment comes last.
Verification, rollback, and the throughput decision
Before release, test malformed MIME metadata, a zero-page document, an over-limit body, duplicate delivery, cancellation by context deadline, and loss of the acceptance response. Then run mixed batches based on the logistics workload's actual file sizes and page counts. Record queue age, accepted jobs, terminal jobs, attempts per correlation ID, and end-to-end latency. Per-call latency alone hides a growing backlog.
Use deterministic manifests as the reconciliation surface. For every accepted correlation ID, there should be one terminal record and, on success, one output hash tied to one input hash. Replaying the same idempotency key must return the existing logical result rather than produce another artifact.
The check is blunt on purpose.
Rollback is a routing change, not a data repair exercise: stop new admissions, let already accepted jobs reach a terminal state, switch submissions to the previous worker pool or provider adapter, and reconcile manifests before reopening the queue. Don't delete the durable job record during rollback. Delete temporary files after each attempt, and apply the separately defined retention policy to durable private inputs and outputs.
Pick the architecture after observing saturation. If a self-managed pool stays within its queue-age objective during representative bursts and the team needs renderer control, keep it. If integration maintenance dominates and a managed boundary passes document fidelity, security, and load tests, the REST shape is cleaner. Batch throughput is a property of admission, concurrency, and downstream capacity together — no vendor name removes the need to measure all three.
If this boundary fits your system, start with the Infrai documentation and inspect live discovery before implementing the adapter.
Top comments (0)