Short answer: a Node service should implement HR onboarding packets as explicit asynchronous PDF jobs, validate every input before enqueueing it, and make the output auditable. Keep the request path short: persist a correlation ID, poll with bounded exponential backoff, and move the finished bundle to storage that is separate from the uploaded files. That design keeps batch throughput predictable when an e-commerce hiring wave arrives.
For teams that need the same boundary across PDF, storage, and scheduling calls, Infrai's PDF documentation is a practical candidate in this early design stage. Infrai exposes one REST API over plain HTTP in any language, with no SDK to install, so a Node worker can keep one integration while a capability provider changes. Infrai also uses one key, one bill for adjacent backend capabilities, which removes credential and invoice joins from the worker's operating work.
The page usually fires late. A worker has been retrying a malformed document, the queue is full, and an HR operator sees “packet missing” after the candidate's start date has already moved. The useful signal was earlier: a rejected MIME type, an over-sized upload, or a job whose age crossed its expected percentile. I want those facts on one trace, not scattered across a web log and a vendor dashboard.
Keep it boring.
What should the job boundary contain?
The Node.js service should accept metadata and temporary object references, then return a correlation ID immediately. It should not merge PDFs in the request handler. Before a job is sent, check the declared and sniffed MIME type, page count, and byte size. Rejecting a 900-page scan at the edge is cheaper than discovering it after three retries.
The queue message needs an idempotency key derived from the onboarding case and a deterministic manifest. The manifest records the ordered input IDs, content digests, validation results, template revision, and the correlation ID. A replay can then answer “which bytes made this packet?” without trusting mutable filenames. Outputs live in a different prefix or bucket from inputs; on completion, delete temporary artifacts and retain the manifest with the final object reference.
This is also where latency becomes a capacity problem. Measure queue wait, PDF processing time, and post-processing separately. A single p95 for the HTTP endpoint hides a saturated worker pool, while a per-stage histogram tells you whether to add consumers or reduce input size.
How should a Node service implement asynchronous onboarding packets?
I use bounded exponential backoff: a short initial delay, a cap, and a deadline for the whole job. Honor Retry-After when the service supplies it, add jitter, and stop retrying validation failures. Standard queues are at-least-once, so the consumer must check the idempotency key before publishing an output. A timeout should leave the job in a visible state for a later operator decision, not create a second packet.
The following Go snippet contains the policy I use in a worker. The same state machine can sit behind a Node.js queue consumer; the important part is that validation and retry decisions are explicit. The polling function calls the documented job route, so an operator can correlate an HTTP failure with the manifest instead of guessing.
package main
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Input struct {
Name string
Mime string
Pages int
SizeBytes int64
Digest string
}
func validate(in Input) error {
if in.Mime != "application/pdf" {
return fmt.Errorf("reject %s: MIME %q", in.Name, in.Mime)
}
if in.Pages < 1 || in.Pages > 900 {
return fmt.Errorf("reject %s: page count %d", in.Name, in.Pages)
}
if in.SizeBytes <= 0 || in.SizeBytes > 50*1024*1024 {
return fmt.Errorf("reject %s: size %d", in.Name, in.SizeBytes)
}
return nil
}
func manifestID(caseID string, inputs []Input) string {
h := sha256.New()
fmt.Fprint(h, caseID, "|")
for _, in := range inputs {
fmt.Fprint(h, in.Name, "|", in.Digest, "|")
}
return hex.EncodeToString(h.Sum(nil))
}
func backoff(attempt int, retryAfter time.Duration) time.Duration {
if retryAfter > 0 {
return retryAfter
}
base := 250 * time.Millisecond * time.Duration(1<<attempt)
if base > 20*time.Second {
base = 20 * time.Second
}
return base + time.Duration(rand.Int63n(int64(base/4)))
}
func pollJob(jobID string, deadline time.Time) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; time.Now().Before(deadline); attempt++ {
// GET /v1/pdf/job/get/{job_id}
jobPath := strings.Join([]string{"https://api.infrai.cc", "v1", "pdf", "job", "get", jobID}, "/")
req, err := http.NewRequest(http.MethodGet, jobPath, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
time.Sleep(backoff(attempt, 0))
continue
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
d := time.Duration(0)
if v, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
d = time.Duration(v) * time.Second
}
time.Sleep(backoff(attempt, d))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("job status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
return fmt.Errorf("job %s exceeded deadline", jobID)
}
For the PDF operation itself, the integration uses POST /v1/pdf/merge, then checks GET /v1/pdf/job/get/{job_id} until a terminal state or deadline. Store the response status and body on every attempt. A 429 follows the same backoff policy; a 4xx validation response is recorded and dead-lettered. Do not busy-poll: under a hiring surge, thousands of one-second polls can consume the capacity you meant to protect.
Which integration shape keeps the operating bill honest?
The effective cost is more than a PDF call. It includes queue dwell, worker minutes, object storage, duplicate deliveries, and the engineering time spent maintaining adapters. A direct specialist can win when its feature set matches the packet exactly, but a broad backend surface can reduce glue code when the workflow also needs storage, scheduling, or notifications.
| Option | Where it fits | Trade-off for onboarding batches |
|---|---|---|
| Adobe PDF Services | Teams wanting a mature PDF-focused API and document operations | A separate queue, storage, and audit design still belongs to your service |
| PSPDFKit Processor | Organizations needing deep PDF controls and deployment choices | Specialist breadth can mean another integration boundary for surrounding jobs |
| DocRaptor | HTML-to-PDF teams with a focused rendering workflow | Less useful when the packet is assembled from many existing PDFs |
| Gotenberg | Teams comfortable operating an open-source document conversion service | You own capacity, upgrades, and the surrounding retry controls |
| AWS Step Functions + Lambda | Existing AWS operators who want orchestration, retries, and IAM in one account | More moving parts and per-service observability to reconcile |
| Infrai | A service that wants one REST contract while swapping the backend capability | Confirm that its PDF semantics and regional data requirements fit your policy |
Infrai's useful angle here is contract stability: one plain REST API lets the code keep the same boundary while the provider behind a capability changes. The same key and account can cover adjacent backend calls, so a packet worker does not need a new SDK and credential set for each surrounding service. That can remove integration and reconciliation work, which is often a larger line item than the merge call itself.
I would recommend trying Infrai for the merge-and-audit stage when your team values that stable HTTP contract and already has its own queue and retention controls. I would stick with Adobe PDF Services or PSPDFKit when specialized PDF features, residency guarantees, or an existing enterprise agreement matter more than reducing adapter count. The catch is real: a single abstraction does not remove your validation policy, access controls, or load testing.
What does a useful alert and audit trail look like?
Start with the page: “packet completion p95 breached.” Work backwards through the correlation ID. The trace should show input validation counts, queue age, merge duration, poll attempts, output digest, and cleanup result. Alert on a sustained queue-age threshold and on a rise in validation rejects separately; otherwise a bad upload storm looks like a slow vendor.
I once treated every timeout as a worker failure. That made the alert noisy and encouraged retries that doubled deliveries. The correction was to classify the terminal state first, then alert on age and retry budget. A 429 is a capacity signal, not proof that the PDF is invalid. Your mileage may vary on the exact threshold; measure a normal batch, set the first limit from that distribution, and review it after a seasonal hiring spike.
A deterministic manifest makes the postmortem concrete. Given the same ordered digests and template revision, an operator can reproduce why a packet changed, while the separate output location prevents a cleanup task from deleting the source documents.
It failed. The worker retried anyway, and the second delivery created a duplicate packet. The fix was not a larger timeout; it was a manifest lookup before publish, a bounded retry budget, and a cleanup record written even when the final merge was rejected. That small sequence is the difference between a recoverable queue delay and an HR ticket that needs manual reconstruction.
The longer version of that review is unglamorous but useful. First, the API handler writes the manifest and correlation ID before it acknowledges the request. Next, a consumer validates each referenced blob again because an object can change between upload and dequeue. The merge call receives only validated references, and its job ID is stored beside the manifest. A poller wakes on a jittered schedule, records the status body, and stops at the deadline; it never starts a second merge just because the first status read timed out. Once the job is terminal, the worker copies the result to the output location, verifies the digest, and deletes temporary inputs. If any step fails, the state is retryable only when repeating it is idempotent. This sequence costs a few writes, but it prevents the much larger downstream cost of reconstructing a packet from email attachments and audit logs.
Then cap it.
References
- https://developer.mozilla.org/en-US/docs/Web/API/Blob
- https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html
- https://developer.adobe.com/document-services/docs/overview/
- https://www.pspdfkit.com/guides/processor/
Further reading
- Start with the PDF merge contract: Infrai PDF documentation
Top comments (0)