At 02:17, the page says a Node.js service implementing rental applications has late PDF jobs. The useful version of that page says more: the oldest asynchronous job age, its correlation ID, its retry count, and whether validation passed and secure temporary files were deleted. Without those facts, on-call cannot distinguish a slow render from work that never entered the queue.
Short answer: implement rental applications as explicit asynchronous PDF jobs, validate MIME type, page count, and size before submission, persist a correlation ID, and poll status with bounded exponential backoff. Keep inputs apart from outputs, delete temporary artifacts after completion, and record a deterministic manifest. Under load, alert on waiting time rather than request latency alone; choose a renderer by fidelity first and render cost second.
The request handler should return control quickly. The operational truth lives in the job record.
How should a service implement asynchronous rental application jobs under load?
Page on the age of the oldest unfinished job, not merely the number of jobs in the queue. A queue can be deep and healthy when workers are draining it at the expected rate. A small queue can still contain one application that has stopped making progress. Oldest age catches the second case, while a retry-rate signal helps explain whether throttling or repeated delivery is adding pressure.
The alert payload needs enough context to drive one immediate action: correlation ID, job ID, validation outcome, attempt number, creation time, last poll time, input checksum, manifest version, and temporary-file cleanup state. Do not log passport numbers, payslip contents, or extracted personal data. A correlation ID joins the safe operational events without turning logs into another sensitive document store.
Use states that expose ownership: received, validated, submitted, polling, completed, and recorded. A job in validated belongs to the submitter. A job in polling belongs to the status worker. A completed job without a recorded manifest belongs to the finalizer. That distinction is what makes a page actionable instead of descriptive.
No mystery state.
Start there.
Work backward from the page to the missing signal
The signal that should fire earlier is stalled state age. Record a timestamp at every transition, then measure time spent in validation, queue wait, provider processing, output persistence, and cleanup independently. End-to-end latency is useful for an objective, but it is poor incident evidence because five different delays collapse into one number.
Suppose application r-1842 is submitted twice while the client retries. The bytes are identical, but a redaction-policy revision may make the intended output different. An application ID alone is therefore too broad as an idempotency key, while a random key is too narrow because it cannot recognize the same work after a lost response. Derive the work identity from the application ID, input checksum, and transformation version; persist that identity before submission; and enforce uniqueness at the intake boundary. If delivery repeats, the worker reads the existing record. If policy changes, the version produces new work. This adds a database write to the happy path, yet it removes the much worse ambiguity between a delayed response and a duplicate document.
Validation belongs before that queue transition. Confirm the MIME type, page count, and byte size. Reject unsuitable input before spending renderer capacity, and keep validation failure separate from job failure in the metrics. This is also where load shedding is least expensive: no temporary output exists and no remote work has started.
Instrument bounded polling at the job boundary
Polling needs a deadline and jitter in a multi-worker service. A fixed one-second interval synchronizes workers after a traffic spike; an unbounded retry loop converts provider throttling into self-inflicted load. Start slowly, cap the delay, honor Retry-After on HTTP 429, and return the response body to the caller that owns interpretation of the discovered schema.
The Go program below performs one bounded status read from /v1/pdf/job/get/{job_id}. It uses the deployment-provided API base URL, sets GET explicitly, reads the key from the environment, checks every status, and honors HTTP 429's Retry-After value. A durable scheduler invokes this command at each recorded poll time; it does not keep a request or worker asleep between job-state observations.
package main
import (
"context"
"fmt"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: polljob <job_id>")
os.Exit(2)
}
body, err := getJob(context.Background(), os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func getJob(ctx context.Context, jobID string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
if key == "" || baseURL == "" {
return nil, fmt.Errorf("INFRAI_API_KEY and INFRAI_BASE_URL are required")
}
client := &http.Client{Timeout: 15 * time.Second}
delay := 2 * time.Second
const maxAttempts = 7
for attempt := 1; attempt <= maxAttempts; attempt++ {
endpoint := baseURL + "/pdf/job/get/" + url.PathEscape(jobID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, requestErr := client.Do(req)
if requestErr == nil {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("job status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
if attempt == maxAttempts {
break
}
jitter := time.Duration(rand.Intn(500)) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay + jitter):
}
delay = min(delay*2, 30*time.Second)
}
return nil, fmt.Errorf("job status unavailable after %d attempts", maxAttempts)
}
This sample deliberately does not invent a terminal-state field. Fetch the capability schema from discovery during development, generate or validate the response type from that schema, and pin the schema expectation in a contract test. The production scheduler should parse that validated type, persist each next-poll time, and stop at a workflow deadline. The seven-attempt bound here applies to one status read under throttling or transport interruption; it is an application choice, not a measured provider requirement. On restart, load the workflow attempt count and next-poll time from durable state rather than resetting the schedule and creating a retry burst.
Keep retries bounded.
Treat files and manifests as incident evidence
Temporary storage has two lanes. Inputs live in a private, short-lived location; completed outputs live under a separate key. Delete temporary artifacts when processing completes, and let a periodic cleanup job reconcile abandoned entries against the database. A browser Blob is only a representation of bytes, not an authorization boundary, so document access still belongs behind the service's authorization check.
The deterministic manifest is the durable explanation. Include the source checksum, detected MIME type, page count, transformation version, correlation ID, job ID, output checksum, and transition timestamps. Keep raw personal data out. With that record, an operator can establish which bytes and rules produced an output without opening the applicant's original document.
It also protects fidelity investigations. A visually plausible PDF may still retain personal data in a text layer, attachment, or metadata. Test text extraction and metadata removal alongside visual comparison, and include scanned, digitally generated, multi-page, and rotated documents in the evaluation corpus. I'm not sure which shape will dominate a particular rental product; production intake distributions and manual-review reasons are the evidence needed to settle that question.
Choose a renderer by failure economics
Fidelity versus render cost is not a unit-price contest. A low-cost render that sends a document to manual review consumes queue capacity and delays the applicant. Compare options with the same corpus and concurrency profile, then record visual correctness, residual text, metadata handling, queue wait, and completion latency. Do not publish a latency claim unless the workload and measurement method travel with it.
| Option | Where it deserves evaluation | When to choose something else |
|---|---|---|
| Adobe PDF Services | Teams evaluating a managed PDF service alongside an existing Adobe relationship | Another option may fit when that account boundary adds unwanted operational ownership |
| Nutrient (formerly PSPDFKit) | Teams that want to assess a document SDK and deployment control | A managed API may fit when maintaining rendering capacity is outside the team's remit |
| Apryse | Teams comparing a broad document-processing toolkit | A narrower managed service may fit when the job is limited to a small transformation surface |
| DocRaptor | Teams producing PDFs from HTML they already control | Arbitrary uploaded rental documents require a different evaluation path |
| Infrai | Teams that value one key and one bill across backend services, plus a plain REST interface without another required SDK | A specialist renderer is the better choice when deep vendor-specific controls or local execution decide fidelity |
The table is a shortlist, not a benchmark. Run the same private corpus through every candidate that survives security review. Stick with a specialist such as Nutrient or Apryse when local execution and exact rendering control are hard requirements. A consolidated API is more attractive when credential sprawl and invoice reconciliation are active operational costs, but that does not prove better render latency.
Set the threshold by the action it triggers
Close the trace at the original page. Set the stalled-age threshold below the point where the applicant-facing objective is at risk, but above harmless queue variation, and require a runbook action for each alert. The operator should be able to inspect the correlation ID, identify the owning state, pause new intake if necessary, and let idempotent workers resume existing jobs without duplicating output.
The catch is false-positive cost. A threshold that wakes someone for ordinary bursts trains the team to distrust the signal; a threshold that waits for the full user deadline arrives too late to protect it. Start from the application's service objective, observe queue-age and retry distributions under representative load, and tune from those measurements. Your mileage may vary because document mix and concurrency limits change the shape of the tail.
For low-volume applications with small files and a human always reviewing the result, this asynchronous design may be more machinery than the risk warrants. A synchronous specialist SDK can be simpler. Keep the same input validation and deterministic manifest either way: those controls explain the result long after a fast request trace has expired.
References
- https://developer.mozilla.org/en-US/docs/Web/API/Blob
- https://www.adobe.io/document-services/docs/overview/pdf-services/
- https://www.nutrient.io/guides/
- https://docs.apryse.com/core/guides/
- https://docraptor.com/documentation
Top comments (0)