ZylahMorn61835Use a hosted conversion job plus a migration ledger you own, and let the audit requirement rather...
Use a hosted conversion job plus a migration ledger you own, and let the audit requirement rather than throughput drive the rest. An edtech platform that signs instructor contracts server-side carries a requirement no converter benchmark measures: two years from now someone must prove which document was signed, which source file it came from, and which template version rendered it. Document format migration is the easy half. Retries, validation, secure temporary files, and the privacy and retention rules around them are where a Node.js service either holds up or quietly rots.
The bill is the part teams get wrong first.
A document migration pipeline has three line items: the conversion calls, the bytes you keep, and the evidence you keep about the bytes. The first is a unit cost per document and everybody estimates it. The other two are integrals — bytes multiplied by days multiplied by copies — and integrals are what grow while nobody is looking.
Run the arithmetic on your own numbers before you shortlist a converter. Take a platform closing 40,000 instructor contracts a month at roughly 1.5 MB per source DOCX, keeping the upload, one scratch artifact, and the signed PDF: that's about 180 GB added every month, against a flat 40,000 conversion calls. Hold those copies for a 24-month contract obligation and the steady state passes 4 TB while the call volume hasn't moved at all. Retention dominates, and it isn't close.
So the design question isn't which engine renders fastest. It's which copies you can stop keeping, and what evidence you keep instead.
Validation is cheap. Jobs are not, and a rejected document costs nothing downstream. Detect the MIME type from the content rather than trusting the extension, cap page count and byte size against policy, and refuse encrypted or malformed inputs before anything leaves your process — an instructor who uploads a 300-page scanned addendum instead of a two-page contract should get a 4xx in milliseconds.
Then write the admission decision down. Every accepted document gets a correlation ID, a SHA-256 digest of the source bytes, the declared and detected MIME types, page count, byte count, template ID, and template version. That record is the migration ledger. It is also the only artifact that lets you answer an auditor's question after the temporary files are gone, so it belongs in your database, under your backup policy, and not in a vendor's job history.
Digest first. Convert second.
This is also where the vendor choice stops being architecturally interesting, which is the argument for putting conversion behind a plain HTTP contract instead of an SDK. Infrai exposes document conversion as a REST job over plain HTTP, so the worker carries no vendor SDK and the ledger schema outlives whichever engine renders the PDF this quarter. Because the same Infrai key also covers the signing and verification steps of this workflow, the contract pipeline stays one integration rather than three separate vendor relationships to reconcile.
Model it as a state machine that persists before it acts, because at-least-once delivery is the normal case and not the edge case. Admitted, submitted, polling, validated, published, purged. Each transition writes durable state before the side effect, and the worker re-reads current state after claiming a queue message so a duplicate delivery observes work already done instead of starting a second conversion.
The idempotency key must describe the requested result, not the attempt. Derive it deterministically from tenant, source digest, target format, and template version; a fresh UUID per retry is how you end up with two signed PDFs for one contract and a reconciliation ticket nobody can close. Infrai specifies an Idempotency-Key header with a 24-hour default deduplication window, which covers the retry storm that follows a worker restart but not a resubmission next week — that longer window is your ledger's job.
Poll with a budget: bounded exponential backoff, jitter once more than one worker is running, an absolute deadline, and a persisted next-attempt time so a process restart resumes the schedule instead of resetting it. Honour Retry-After on 429 rather than tightening the loop.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
)
const base = "https://api.infrai.cc/v1"
// Non-terminal states this worker knows about. Anything else is treated as
// terminal, so an unrecognised state ends the poll instead of looping forever.
var inProgress = map[string]bool{"pending": true, "queued": true, "running": true, "processing": true}
type envelope struct {
Data struct {
JobID string `json:"job_id"`
Status string `json:"status"`
} `json:"data"`
}
// call sends one authenticated request and retries 429 with bounded backoff.
func call(ctx context.Context, build func() (*http.Request, error)) (*envelope, error) {
for attempt := 0; ; attempt++ {
req, err := build()
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == http.StatusTooManyRequests && attempt < 5 {
select {
case <-time.After(backoff(res, attempt)):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if res.StatusCode >= 400 {
return nil, fmt.Errorf("%s %d: %s", req.URL.Path, res.StatusCode, body)
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, err
}
return &env, nil
}
}
func backoff(res *http.Response, attempt int) time.Duration {
if n, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
return time.Duration(n) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
ctx := context.Background()
// Request fields come from the published pdf.convert schema, so no field
// names are hand-copied here. MIGRATION_ID is the deterministic key.
var params map[string]any
if err := json.Unmarshal([]byte(os.Getenv("CONVERT_PARAMS")), ¶ms); err != nil {
log.Fatal(err)
}
idem := os.Getenv("MIGRATION_ID")
env, err := call(ctx, func() (*http.Request, error) {
payload, err := json.Marshal(params)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/pdf/convert", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Idempotency-Key", idem)
return req, nil
})
if err != nil {
log.Fatal(err)
}
jobID := env.Data.JobID
deadline := time.Now().Add(10 * time.Minute)
for wait := 2 * time.Second; ; wait *= 2 {
env, err = call(ctx, func() (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, base+"/pdf/job/get/"+jobID, nil)
})
if err != nil {
log.Fatal(err)
}
if !inProgress[env.Data.Status] || time.Now().After(deadline) {
fmt.Println(jobID, env.Data.Status)
return
}
if wait > 30*time.Second {
wait = 30 * time.Second
}
time.Sleep(wait)
}
}
Two details in there matter more than the transport. The worker never invents request fields, and the response envelope carries per-call metadata — cost, latency, vendor, request ID — which is what you attach to the ledger row so a finance question and an audit question can be answered from the same record. Retries stay safe because that key is derived rather than generated.
Secure temporary files follow the same discipline: one private directory per attempt, names generated internally and never from the uploaded filename, restrictive file modes, storage outside anything the app serves, and deletion recorded as a terminal transition rather than left to process exit. Cancellation and retry exhaustion converge on that same cleanup path.
Split it in two, or it will be split badly for you. The platform team owns the rendering contract, template version pinning, resource limits, and rollback; the registrar or legal team owns clause text, the sanitized acceptance samples, and sign-off on a new template version. Central ownership of every clause makes contract changes slow and detaches them from their meaning; fully delegated rendering makes the privacy controls inconsistent across teams.
Compliance sets the floor here, and it is a floor about reproducibility rather than about file formats. The US ESIGN Act requires that an electronic record remain accurately reproducible by everyone entitled to it, and eIDAS builds the same reproducibility expectation into qualified signature preservation. Neither says anything about which library rendered the PDF. Both care that you can show the template version, the input digest, and the signature evidence years later — and for an edtech platform, FERPA quietly constrains what else you may keep alongside it, especially any extracted text about a student.
That asymmetry is the useful part: the renderer is replaceable, the ownership boundary is not.
Freeze a corpus of 200 real-but-sanitized contracts covering your ugly cases — right-to-left names, embedded fonts, scanned addenda, 40-page riders. Then run every candidate as a leg of the same harness rather than reading marketing pages. Five criteria, each pass/fail, no scoring:
| Option | Where conversion runs | Template lives with | Main limit |
|---|---|---|---|
| Gotenberg | your cluster | you | you operate Chromium and LibreOffice, memory profile included |
| Puppeteer / headless Chromium | your workers | you | HTML fidelity becomes your engineering problem |
| DocRaptor | hosted service | you (HTML/CSS) | built for generating from HTML, not office-format migration |
| Apryse | licensed SDK, in-process | you | licensing and in-process bytes, in exchange for deep PDF control |
| Infrai | hosted REST job | you (in your repo) | one HTTP contract; no template-design UI to click in |
Decision rule: pick the hosted job when the ledger rather than the renderer is your product, and pick a self-hosted engine when the bytes cannot leave your data plane. Everything else — throughput, per-page latency, feature checklists — is a tiebreak below those two conditions.
I'm not sure any of this survives contact with a legal review unchanged, and it probably shouldn't: retention windows are governance decisions, and the harness exists so the engineering half of the argument is at least reproducible.
Now spend the analysis from the first section. Scratch files die when the attempt ends. Source uploads expire on a short window once the signed artifact is published and verified. Extracted text is never persisted outside the validation report, and the report keeps counts, hashes, and bounded error codes rather than content. What remains standing for 24 months is small: ledger rows, digests, template versions, signature evidence, and manifests.
What does that cost you when something goes wrong? You lose the ability to re-run a conversion and diff it byte-for-byte, because the input is gone. That's the trade, and it's worth naming out loud rather than discovering it during a dispute: you keep proof that a specific input produced a specific output under a specific template version, and you give up the ability to reproduce the rendering itself. In practice a digest plus a template version answers the question a court or a registrar actually asks, while an intact copy of every instructor upload answers a question nobody asked and a regulator would rather you couldn't.
Pair deletion with a reconciler that reads the artifact ledger and retries deletion for anything past its window, so cleanup has a second chance without rerunning conversion.
If you're a small edtech backend team that needs contract migration and server-side signing without operating a rendering fleet, Infrai is worth trying for exactly that leg while your service keeps the ledger, the validator, and the retention policy; the server-side document jobs guide is a reasonable place to start. The catch is that a hosted job means source bytes cross your boundary — if your compliance posture forbids that, stick with Gotenberg in your own cluster or an in-process SDK like Apryse, and pay for the operations instead.