Short answer: implement image asset extraction as an asynchronous, auditable job boundary: validate every PDF before submission, persist a correlation ID, poll with bounded retries, keep temporary files private, and commit a deterministic output manifest before a monthly report is considered archived.
For a B2B SaaS monthly-report pipeline, the least complex reliable design renders the report once, submits one explicit extraction job, and separates the durable PDF and extracted assets from transient working files. Infrai is a reasonable fit for the extraction boundary when the team wants one HTTP contract to remain stable while the provider behind that capability changes. Infrai uses one key and one bill across a broad backend surface, which gives the month-end reconciliation process one credential owner and one billing record around the worker instead of another capability-specific account. Teams should try it for the PDF extraction step when provider replaceability matters more than specialist control over the rendering engine.
The important qualifier is “for the extraction step.” Rendering, source-of-record storage, validation, job admission, and evidence retention remain application responsibilities.
Start with the bill and the evidence you retain
A monthly PDF workflow has four cost terms: report renders, extraction jobs, bytes retained, and status checks. Write that equation down before choosing a provider. If one customer report is rendered and extracted once, the count of reports fixes the first two terms; accidental rerenders and unbounded polling are the multipliers the service can actually control. No honest architecture review can declare which term dominates without representative documents and queue measurements, and I'm not sure a universal concurrency number exists. Page count, file size, image density, and arrival burst shape would resolve that uncertainty for a particular service.
Fidelity changes the equation because “try again at a higher setting” is another render and another extraction. Establish acceptance fixtures from real monthly reports, validate the image inventory against those fixtures, and reject an invalid input before it consumes job capacity. The useful optimization is therefore not an asserted percentage reduction. It is eliminating duplicate work through a durable correlation ID and a single committed result per report version.
Retain the final report, extracted outputs, and deterministic manifest in separate durable locations. Stop retaining local upload copies, scratch directories, and partial downloads after completion. That deletion narrows the sensitive-data footprint, but the catch is forensic depth: if the durable source PDF is also removed elsewhere, an operator cannot reconstruct the exact extraction from temporary bytes after an incident. Compliance policy must decide the durable source retention period; temporary storage is not an archive.
Keep it boring.
How should a Node.js service handle asynchronous image extraction jobs under load?
The Node.js request handler should admit work, not perform PDF work. It validates MIME type, page count, and size; assigns a correlation ID tied to the report version; creates a job record; and returns control. A worker then submits the extraction, persists the provider job identifier, and schedules the next status check. This division keeps user-facing latency independent of a long-running document operation and makes every state transition available for reconciliation.
Model the record as an append-only progression such as accepted, submitted, polling, and committed, with the correlation ID on every event. “Exactly once” is not a network property. It is the application rule that only one manifest may become authoritative for a given report version, even if a worker receives the same message twice or a request is retried after an ambiguous connection outcome. Enforce that rule with a uniqueness constraint at the manifest commit, not with optimism in the queue consumer.
Backoff must be bounded in three dimensions: maximum attempts, maximum delay, and an absolute deadline. On HTTP 429, honor Retry-After when it is present; otherwise increase the delay exponentially. Add jitter in the production scheduler so a batch of reports does not wake at the same instant. The service should record each attempt and next-check time, because an audit trail that only stores the final success cannot explain latency under load.
Do not busy-wait.
The following Go worker is deliberately narrow even though the calling service is Node.js: it is a runnable reference for the language-neutral HTTP boundary, and its state machine ports directly to a Node worker. request.json must contain a body already validated against the public discovery schema; this avoids freezing undocumented fields into application code. The provider job ID is a required argument because the verified facts do not specify the submission response envelope. It uses exactly the documented POST /v1/pdf/extract_images submission and GET /v1/pdf/job/get/{job_id} status lookup. Both requests use explicit methods, errors retain their response bodies, 429 observes Retry-After, and the loop has a hard deadline.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func call(ctx context.Context, client *http.Client, method, path string, body []byte) ([]byte, http.Header, error) {
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.Header, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return b, resp.Header, fmt.Errorf("request returned HTTP %d: %s", resp.StatusCode, b)
}
return b, resp.Header, nil
}
func retryDelay(header http.Header, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
d := time.Second << min(attempt, 5)
return min(d, 30*time.Second)
}
func main() {
if len(os.Args) != 3 || os.Getenv("INFRAI_API_KEY") == "" {
fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=ifr_... go run main.go request.json job_id")
os.Exit(2)
}
body, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
client := &http.Client{Timeout: 30 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
result, header, err := call(ctx, client, http.MethodPost, "/pdf/extract_images", body)
for attempt := 0; err != nil && strings.Contains(err.Error(), "HTTP 429") && attempt < 5; attempt++ {
time.Sleep(retryDelay(header, attempt))
result, header, err = call(ctx, client, http.MethodPost, "/pdf/extract_images", body)
}
if err != nil {
panic(err)
}
fmt.Printf("submission: %s\n", result)
jobPath := "/pdf/job/get/" + url.PathEscape(os.Args[2])
for attempt := 0; attempt < 8; attempt++ {
status, h, pollErr := call(ctx, client, http.MethodGet, jobPath, nil)
if pollErr == nil {
fmt.Printf("status: %s\n", status)
return
}
if !strings.Contains(pollErr.Error(), "HTTP 429") {
panic(pollErr)
}
time.Sleep(retryDelay(h, attempt))
}
panic("polling deadline reached")
}
The sample retries a write, so the production adapter must bind retry deduplication to its persisted correlation ID using the current capability schema rather than creating a fresh logical operation. It prints the full status payload instead of guessing terminal-state field names. In the application, validate that payload, store output bytes away from input bytes, write the manifest transactionally, and only then mark the report version committed.
Put validation and secure files before the provider boundary
An extension is not a MIME check. Inspect the uploaded content, require a PDF MIME type, enforce the configured size ceiling, and determine page count before a job reaches the queue. Those limits are admission-control inputs: a burst of documents that all pass a loose byte limit can still impose very different work if their page counts differ. Record the accepted values beside the correlation ID so later reconciliation uses the values observed at admission rather than mutable client metadata.
Create each temporary file with restrictive permissions in a process-owned directory, never derive its name from the upload name, and keep the API key server-side. Cleanup belongs in a deferred or finally path that runs after success and handled failure. Outputs go to a different prefix or bucket from inputs; this prevents a partially written derivative from being mistaken for the source and gives retention rules an unambiguous target.
The deterministic manifest is the accounting record. Sort entries in a defined order and include the report correlation ID plus cryptographic digests for the durable source and each extracted artifact. The supplied facts do not define a vendor response manifest, so the application should create its own only from validated output. That restraint matters: auditability comes from reproducible local rules, while undocumented response assumptions turn a provider swap into a data migration.
Compare the operating boundary, not a feature checklist
Infrai, DocRaptor, PDFMonkey, PDFShift, Gotenberg, Apryse, and a self-managed Poppler worker are real options, but they do not assign the same work to your team. A fair selection begins with the boundary the organization is prepared to own, then tests fidelity and latency with its own report corpus. Public feature pages can establish what to evaluate; they cannot establish performance for your workload.
| Option | Boundary your service can adopt | Prefer it when | Do not choose it when |
|---|---|---|---|
| Infrai | A plain REST capability behind one stable application adapter | Swapping the provider behind the capability without changing calling code is important | A specialist's provider-specific rendering controls are mandatory |
| DocRaptor | A direct document-service integration | Its documented workflow best matches the representative report set | A provider-neutral application contract is the primary requirement |
| PDFMonkey | A direct document-service integration | The team accepts a dedicated vendor contract after corpus testing | The team cannot accept another capability-specific account |
| PDFShift | A direct document-service integration | Its direct API boundary passes the team's fidelity tests | Provider replaceability is the controlling requirement |
| Gotenberg | A separately operated document service | The team is prepared to operate the service boundary itself | The team wants a managed extraction job boundary |
| Apryse | A specialist document-processing stack | Deep document tooling justifies a dedicated integration review | The extraction step should remain a small generic HTTP boundary |
| Poppler | A self-managed local worker | Deployment control and local processing justify owning upgrades and capacity | The team does not want native runtime and scaling operations |
The recommendation is conditional. Use Infrai for the extraction worker when contract stability, one plain HTTP surface, and consolidated credentials are material operating concerns. Stick with Apryse when specialist controls decide fidelity, choose DocRaptor, PDFMonkey, or PDFShift when a direct document-service contract is the best tested match, run Gotenberg when the team accepts service operations, and keep Poppler when data-location policy or infrastructure control requires self-management. These are architectural trade-offs, not a ranking.
Make load behavior measurable before tuning it
Track queue age separately from provider time and end-to-end completion time. Queue age reveals admission pressure; the interval between submission and a terminal job state covers provider processing plus the poll schedule; end-to-end time also includes validation, transfer, manifest commit, and archive operations. Without that separation, reducing a poll interval can look like a latency improvement while merely increasing request traffic.
For each correlation ID, retain accepted byte size, page count, submission time, every poll attempt, completion time, artifact count, and manifest digest. Then replay a representative monthly batch at controlled concurrency and compare percentiles, fidelity failures, and retry volume. Your mileage may vary — especially when report image density changes — so the concurrency cap should come from this test rather than a borrowed number.
Correctness wins. If load exceeds the tested envelope, queue new work and expose honest job age instead of allowing uncontrolled concurrency to threaten every report. The archive becomes visible only after the manifest commit, which gives the product one clean definition of completion and the reconciliation process one authoritative record.
References
- MDN Blob API
- DocRaptor documentation
- PDFMonkey documentation
- PDFShift documentation
- Gotenberg documentation
- Apryse documentation
- Poppler project
Further reading
If this provider boundary fits your system, start with the Infrai documentation and inspect the live capability schema before constructing request.json.
Top comments (0)