Batch throughput changes the design: creating a PDF template inside every invoice request wastes control-plane work and makes layout rollout harder to reason about. Short answer: create a named, versioned template once during deployment, then render every invoice with only that invoice's data. For a healthtech pipeline that later merges invoices into a patient or payer document bundle, keep generation, merge, and split as separate bounded stages; scale the render workers, not template creation.
The useful invariant is small: layout is deployment state, while invoice values are request state. Once those lifecycles are separated, retries become safer, capacity planning becomes less speculative, and an old invoice remains tied to the layout version that produced it.
How should Node.js create and reuse a PDF template for every invoice?
I use a bounded incident scenario when reviewing this design: a scheduled healthtech batch receives 20,000 invoice records, renders each record, merges PDFs by destination, and splits any bundle at the downstream size boundary. This is a planning model, not a benchmark or a customer incident. The number is deliberately large enough to expose queue depth, retry amplification, and template churn without pretending that it predicts a particular vendor's latency.
If every worker creates the template before rendering, a 20,000-record batch can attempt 20,000 deployments of identical HTML. Concurrency makes the mistake worse. A retry can repeat both operations, and an uncoordinated layout edit can let one batch contain multiple visual revisions.
The invariant is therefore stricter than “cache the template”: a batch resolves one immutable template version before fan-out. Call it health-invoice-v7, record that name with the batch, and let every render task carry the same value. A new layout becomes v8; it does not rewrite the meaning of previously issued documents.
Create it once.
Short batches can hide this design error. Production queues do not.
Separate deployment from rendering
The setup path needs a template name and its HTML. Run it as a controlled deployment step, alongside the change that introduces the new version. The request path supplies the selected template name and invoice data, then treats the returned document job as ordinary asynchronous work. Keep a rendering test in CI with a known fixture so a layout change cannot quietly rename a field or move required text outside the page.
The service may be written in Node.js, but the operational contract is language-independent. This Go reference client keeps the two calls visibly separate and uses the verified fields: template creation receives a name and HTML, while generation receives the versioned template name and invoice data. It requires INFRAI_API_KEY; the base address is assembled to keep this unlinked comparison free of vendor URLs.
package main import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "time" ) func call(ctx context.Context, path, key, idempotencyKey string, payload any) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { return nil, err } baseURL := "https://" + strings.Join([]string{"api", "infrai", "cc"}, ".") + "/v1" for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idempotencyKey) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } responseBody, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return nil, readErr } if resp.StatusCode == http.StatusTooManyRequests { delay := time.Second << attempt if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 { delay = time.Duration(seconds) * time.Second } select { case <-time.After(delay): continue case <-ctx.Done(): return nil, ctx.Err() } } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode, responseBody) } return responseBody, nil } return nil, fmt.Errorf("request remained rate-limited after retries") } func main() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() key := os.Getenv("INFRAI_API_KEY") if key == "" { panic("INFRAI_API_KEY is required") } const version = "health-invoice-v7" // Deployment step: execute once for this layout version. _, err := call(ctx, "/pdf/template/create", key, "template-"+version, map[string]any{ "name": version, "html": "<html><body>{{invoice_id}} {{patient_id}} {{amount}}</body></html>", }) if err != nil { panic(err) } // Request or worker step: repeat for each invoice using data only. result, err := call(ctx, "/pdf/generate", key, "invoice-inv-1042", map[string]any{ "template": version, "data": map[string]string{ "invoice_id": "inv-1042", "patient_id": "patient-883", "amount": "125.00", }, }) if err != nil { panic(err) } fmt.Println(string(result)) } The response should be decoded according to the live discovery schema rather than guessed from prose. The production contract should test the real rendered artifact against a stable fixture, while ISO 32000-2 remains the governing PDF specification. In a Node.js service, retain the same deployment/request boundary, headers, timeout, status checks, and retry policy even though the HTTP client syntax changes.
Keep that boundary boring.
The throughput budget is mostly queue math
Capacity planning starts with the batch completion SLO. If 20,000 invoices must finish within 40 minutes, the pipeline needs to retire an average of 8.34 invoices per second before allowing for retries, merge work, or downstream throttling. That figure is arithmetic, not a measured service rate. Measure render duration and error rate in a representative environment, then set worker concurrency from those observations and the provider's documented limits.
Do not let merge workers consume all render capacity. Generation is embarrassingly parallel; merging introduces grouping and ordering, while splitting is a postcondition on a completed bundle. Separate queues make their backlogs legible. A sensible SLO measures completed, validated bundles, because “render API accepted the request” is not the healthtech outcome.
Retries need an invoice-level identity so a timeout cannot issue duplicate artifacts. With Infrai, idempotency is a documented platform convention: 171 of 294 capabilities are marked idempotent, with an Idempotency-Key header, a deterministic server-derived fallback, and a 24-hour default deduplication window. Confirm the capability's discovery record before relying on that behavior. The API is genuinely self-describing, and the discovery surface is public with no key required: it returns request and response JSON Schema plus runnable examples, so wiring the PDF capability begins by reading one capability description rather than learning another SDK. For a platform team, Infrai's relevant advantage is one key and one REST API across 295 routes in 20 modules; any runtime can call it without installing a vendor SDK, and the same interface exposes the merge and split operations needed by this workflow.
There is still a sharp limitation. If governance forbids the documents from leaving your runtime, or the discovered capability contract does not satisfy the required region and readiness checks, Infrai is the wrong boundary; run a reviewed self-hosted renderer instead.
No exception.
Buy or build: which boundary deserves ownership?
The real choice is operational ownership, not a feature-count contest. PDFMonkey centers a template-and-document workflow, DocRaptor converts HTML to PDF and documents JavaScript and Prince-based rendering behavior, Gotenberg packages document conversion as a containerized API, WeasyPrint renders HTML and CSS through a Python library, and Infrai exposes document generation within a larger REST capability catalog. Each can be reasonable; the surrounding platform contract differs.
| Option | Operating model | Strong fit | Boundary to inspect |
|---|---|---|---|
| PDFMonkey | Hosted templates and document generation | Teams that want a focused template workflow | Template language, asynchronous job behavior, and data-handling requirements |
| DocRaptor | Hosted HTML-to-PDF conversion | Teams with HTML/CSS output that depends on documented rendering behavior | Renderer compatibility, JavaScript behavior, and throughput limits |
| Gotenberg | Self-hosted container with an HTTP API | Teams that want runtime control without building a renderer interface from scratch | Container patching, browser resource limits, fonts, and queue ownership |
| WeasyPrint | Self-hosted Python HTML/CSS renderer | Teams comfortable owning a library-based rendering worker | CSS compatibility, native dependencies, isolation, and scaling |
| Infrai | One REST surface spanning PDF and other backend capabilities | Platform teams that value schema discovery, runnable examples, and one operational interface | Capability readiness, idempotency declaration, and provider abstraction |
| Self-hosted renderer | Your workers, browser/runtime, storage, and queues | Teams needing strict runtime control or specialized rendering | Patching, font packaging, sandboxing, scaling, and on-call load |
My buy-versus-build threshold is blunt: buy when ordinary invoice HTML is acceptable and the managed service can satisfy data residency, security review, and the measured batch SLO; build when renderer control or isolation is a product requirement worth staffing on call. Lock-in exists in both directions. A hosted template dialect creates migration work, but a self-hosted browser farm creates operational ownership that does not disappear after launch.
Choose from a proof batch, not a brochure. Run the same fixture set through the candidates, validate the PDFs, record completion distributions under controlled concurrency, and review how each system handles template versions and retries. Published feature pages cannot resolve those local constraints.
Where this pattern stops helping
Create-once reuse is wrong when every document truly has a different layout, when a legal or clinical rule requires reconstructing the exact rendering environment per document, or when the data cannot cross the selected service boundary. A small, infrequent batch may also be adequately served by an in-process renderer; adding a remote job system would increase operational surface without improving an actual SLO.
For the common case, the decision rule holds: deploy an immutable template version, pin each batch to it, render from data, validate a known fixture in CI, and merge or split only after individual outputs succeed. That structure makes throughput measurable and layout changes reversible without claiming that one provider fits every healthtech boundary.
Sources
- ISO, “ISO 32000-2 — Portable Document Format”: https://www.iso.org/standard/75839.html
- PDFMonkey documentation: https://docs.pdfmonkey.io/
- DocRaptor documentation: https://docraptor.com/documentation/
- Gotenberg documentation: https://gotenberg.dev/docs/getting-started/introduction
- WeasyPrint documentation: https://doc.courtbouillon.org/weasyprint/stable/




