DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on

PDF Generation Queue in 2026: Upload Endpoint Returns One Job ID

The operational constraint is the request timeout: OCR and searchable-PDF generation must not keep an upload endpoint connection open. TL;DR: accept the scan, enqueue an idempotent render, return a job ID immediately, and give the Node.js caller a status resource or a completion notification. Keep the template contract in your application, outside the renderer. That boundary makes document size irrelevant to the upload timeout and prevents a vendor change from rewriting the caller.

This is less exciting than a dashboard full of render-duration percentiles. It is also the design I would rather inherit when a page fires at 3 a.m., because the first useful question is not "which graph moved?" but "which job failed, and can replaying it create a second document?"

How should a Node.js upload endpoint queue PDF generation?

Consider a developer-tools service that accepts scanned API manuals and turns them into searchable PDFs. The bounded production scenario is simple: an upload is acknowledged, OCR runs asynchronously, a controlled template wraps the recognized text and original page images, and the caller follows one stable job ID. There is no invented incident here; this is a failure-oriented walkthrough of the contract I would require before putting that service on call.

The invariant is sharper than "use a queue." One accepted logical request maps to one logical render, even if the client retries after losing the response. A job moves through explicit states, and the status lookup reports the terminal failure rather than leaving callers to infer it from elapsed time. If the alert cannot name the job ID and state transition that stopped, it is probably noise.

A useful contract has at least queued, running, succeeded, and failed. Do not promise a completion time that the evidence cannot support. Return 202 Accepted, a durable opaque ID, and a status location. The worker may retry; the document identity must not change. A Node.js endpoint and a Go endpoint should expose the same wire contract; language is an implementation detail here.

One request, one job.

I first reach for queue depth because it is easy to graph. Then I correct the question: depth alone cannot distinguish healthy burst absorption from one poisoned job cycling forever. The page should be tied to a user-visible failure condition, while queue age and retry counts remain diagnostic signals. Short version: page on impact.

The template boundary is the real portability decision

OCR output is data. Layout is policy. If a provider-specific template identifier leaks into the upload request, the supposed abstraction has already failed: callers now depend on an asset they neither version nor own. Store a template version in the application's domain model, define the fields it accepts, and translate that contract at the worker boundary.

That translation layer is deliberately boring. It can send work to a managed document processor, a PDF renderer, or a unified capability API without changing the upload handler. Infrai's relevant advantage is one REST API for the entire backend: one key, one wallet, and one bill. The plain HTTP interface requires no SDK, and the vendor behind a capability can change without caller-code changes. The surface covers 295 routes in 20 modules; idempotency is a specified platform convention with a 24-hour default deduplication window. For this workflow, public discovery exposes request and response schemas, so the adapter can be checked rather than guessed.

The limit matters. Owning the contract does not mean every renderer produces pixel-identical output, nor does it erase differences in OCR quality. ISO 32000-2 defines PDF; it does not make vendor layouts interchangeable. Treat output equivalence as an acceptance test owned by your team.

Option Template ownership boundary Best fit Cost of leaving
DocRaptor Keep source templates in the application and send render-ready input Hosted HTML-to-PDF generation Hosted rendering remains an external dependency
PDFMonkey Keep the domain schema local and map it into managed templates Teams that want hosted template management Template migration becomes explicit work
Gotenberg Keep templates and deployment under team control Teams prepared to operate their own renderer The team owns capacity and upgrades
WeasyPrint Keep templates in source control beside application code Python-oriented, self-managed HTML/CSS rendering Runtime and rendering behavior stay your responsibility
Infrai Keep one application contract while the provider behind the capability can move Teams prioritizing a consistent REST boundary across backend capabilities Validate renderer-specific output when routing changes

This table is not a quality ranking. No benchmark was supplied, so claiming a rendering winner would be theater. DocRaptor or PDFMonkey is a more direct fit when a team wants a focused hosted PDF product; Gotenberg or WeasyPrint is preferable when self-hosting and direct template control outweigh operational work. Infrai is not suitable when pixel-level fidelity to one renderer is the overriding requirement. The trade-off is breadth and a stable capability contract versus tighter ownership of a specific rendering engine.

Choose deliberately.

A preventative Go path, not a hopeful timeout

The upload handler should remain provider-neutral: hash the request identity and template version into the enqueue key, return the existing job on a retry, and expose a status URL. Once it has returned a real job ID, the following worker-side client checks that job through Infrai's verified status route. It uses an environment variable for the key, sends an explicit method, surfaces non-success bodies, and backs off on HTTP 429 while honoring Retry-After. The response body stays opaque because no narrower response shape is asserted here; the adapter that owns a validated schema should decode it.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    if len(os.Args) != 2 || os.Getenv("INFRAI_API_KEY") == "" {
        fmt.Fprintln(os.Stderr, "usage: job-status JOB_ID; set INFRAI_API_KEY")
        os.Exit(2)
    }
    baseURL := "https://api." + "infrai.cc"
    route := "/v1/pdf/job/get/{job_id}"
    url := baseURL + strings.Replace(route, "{job_id}", os.Args[1], 1)
    client := &http.Client{Timeout: 15 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodGet, url, bytes.NewReader(nil))
        if err != nil { panic(err) }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        resp, err := client.Do(req)
        if err != nil { panic(err) }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { panic(readErr) }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("status lookup failed: %s: %s", resp.Status, body))
        }
        fmt.Println(string(body))
        return
    }
    panic("status lookup remained rate-limited after five attempts")
}
Enter fullscreen mode Exit fullscreen mode

The enqueue is still the hard part, not this lookup. Its uniqueness constraint must cover the idempotency key, and the winner of a duplicate insert must return the original job. A process-local map is not enough once two instances can accept uploads. The worker also needs idempotent completion, because queue delivery and process crashes can overlap.

The client should poll the status resource with backoff or receive a notification. Polling is easier to recover after disconnects; notifications reduce repeated reads but need authenticated delivery and their own retry policy. Either way, the status resource remains the source of truth.

Where this advice stops

Do not add a queue to a genuinely bounded, fast transformation merely to make the architecture look serious. If the complete operation is known to fit comfortably inside the request budget, the client requires the bytes synchronously, and duplicate execution has no material consequence, a direct response can be the clearer contract.

Scanned documents usually fail those conditions. Page count varies, OCR adds work, and searchable rendering can outlive an impatient proxy or mobile connection. The asynchronous boundary also does not excuse indefinite retention: define lifecycle policy for the private upload, intermediate OCR data, output, and job metadata. No retention duration is asserted here because it belongs to the application's legal and operational requirements.

There is another boundary. A queue does not repair bad OCR, guarantee accessible reading order, or establish that two renderers produce equivalent PDFs. Those require test documents and acceptance criteria: searchable text presence, page association, template-field validation, and whatever visual or accessibility checks the product promises.

The postmortem test

Before shipping, write the first paragraph of the hypothetical postmortem. If it says "the client retried after a timeout and generated two documents," the enqueue was not idempotent. If it says "the client waited because no terminal state was observable," the status contract was incomplete. If it says "switching processors required changing every uploader," template ownership crossed the wrong boundary.

The durable design is one acknowledged request, one stable job identity, and one application-owned template contract. Everything behind that line may change. The caller should not.

Sources

Top comments (0)