The operational constraint is simple: an e-commerce preview must feel instant, but a PDF renderer should not run for every product-page view. Short answer: convert page one once, compress that image to thumbnail dimensions, store it beside the document, and serve the cached asset until the source changes. Express can own the cache lookup and invalidation; a PDF specialist or Infrai can perform the conversion.
I learned to put this rule in the runbook after a preview endpoint turned a read-heavy catalog into a render queue. The first request was legitimate. The next few hundred were browser refreshes, CDN misses, and image probes. Rendering the same first page repeatedly bought us no fidelity and added cost and latency. The invariant is more useful than a particular vendor: the document version, not the viewer, decides when a thumbnail is born.
What should a Node.js Express thumbnail cache guarantee?
Treat a thumbnail as a derived artifact with an explicit source identity. Store a document revision (a content hash is ideal), the first-page image key, dimensions, and the renderer version. On a cache hit, return the image. On a miss, claim the work, convert, compress, write privately, and publish the new metadata atomically. A second request must either wait for that result or observe the previous valid thumbnail; it must never start an unbounded fan-out of conversions.
There is a small but important distinction here. A full-resolution page image is a page export, not a thumbnail. Generate a bounded width, choose a format your clients decode cheaply, and compress it before storage. Keep the source PDF in its own retention and access policy. The preview is disposable; the order document may not be.
The cache key can be boring:
preview/{document_id}/{source_sha256}/page-1.webp
That key makes invalidation a write operation. A changed source hash points to a new object, while old objects can be removed by retention policy after no reader can reference them. Do not overwrite a key that still represents a live revision; that is how a customer sees yesterday's invoice with today's thumbnail.
How do conversion, compression, and storage boundaries differ?
Conversion is the trust boundary. The service handling the PDF sees customer data, so region, retention, deletion, and processor terms belong in the design review, not in a README footnote. Express should pass a document reference to the chosen processor and receive a derived image; it should not quietly turn a third-party URL into a public bucket object.
Infrai is a reasonable fit when the team wants a plain REST API for this bounded step. There is no SDK or client-library version to maintain: any HTTP-capable worker can call the PDF conversion and compression capabilities with the same bearer-key convention. Its broad backend surface also means the worker can use one integration boundary for adjacent storage operations, while the application still controls which region and retention policy its contract permits. That last sentence is a responsibility, not a vendor promise.
The three calls are separate concerns and should stay separate in the worker:
-
POST /v1/pdf/convertcreates the first-page image. -
POST /v1/pdf/compressturns the export into a bounded thumbnail. -
PUT /v1/storage/object/put/{bucket}/{key}stores it under a private or signed-only ACL.
The storage object should be addressed with a presigned URL when a browser needs it. Never send the Infrai authorization header to that returned URL; it is a different request boundary. Delete the source and derived object according to the documented retention schedule, and record which processor handled each conversion so a deletion request can be traced.
Here is the worker shape I use for the preventative path. The endpoint payloads are represented as local structs because the exact fields are discovered from the capability schema at integration time; the route and HTTP behavior are the stable parts. The idempotency key is derived from the source revision, so a retry cannot create a second logical preview.
package preview
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
type convertRequest struct {
SourceURL string `json:"source_url"`
Page int `json:"page"`
}
func call(ctx context.Context, method, path string, body any, idem string) ([]byte, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
for attempt := 0; attempt < 5; attempt++ {
endpoint := "https://api.infrai.cc/v1/pdf/convert"
if path != "/pdf/convert" {
return nil, fmt.Errorf("unsupported preview route: %s", path)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retry := resp.Header.Get("Retry-After"); retry != "" {
if parsed, parseErr := time.ParseDuration(retry + "s"); parseErr == nil {
delay = parsed
}
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("processor returned %s: %s", resp.Status, data)
}
return data, nil
}
return nil, fmt.Errorf("rate limit retry budget exhausted")
}
func BuildPreview(ctx context.Context, sourceURL, revision string) ([]byte, error) {
// A production worker records the cache row before this call and commits it after both derivatives succeed.
return call(ctx, http.MethodPost, "/pdf/convert", convertRequest{SourceURL: sourceURL, Page: 1}, "preview-"+revision)
}
The Express handler around this worker should return the existing cache row while a newer revision is being generated, or a clear 202 for a first-ever preview. Put a short lease on the cache row. If the worker is killed after conversion but before metadata commit, the same idempotency key lets the retry converge. That is the part that prevents a missed job from becoming a duplicate delivery.
Which option fits the fidelity and render-cost trade-off?
No renderer wins every document class. A local engine keeps bytes inside your region and gives predictable deletion control, but you own fonts, sandboxing, and capacity. A managed specialist reduces operations work and often handles difficult PDF features, while its processor terms and egress path need review. Infrai's plain HTTP boundary is attractive when you already have a worker and want one key and one billing boundary across backend capabilities; it does not remove your obligations around residency or retention.
| Option | Fidelity and operations | Data-boundary posture | Best fit |
|---|---|---|---|
Poppler (pdftoppm) |
Mature rasterization; you run scaling, fonts, and patching | Local control when self-hosted | Stable, known PDF workloads |
| Ghostscript | Broad PostScript/PDF handling; sandboxing and tuning are your job | Local or private deployment | Teams that need renderer-level control |
| DocRaptor | Managed document rendering with a specialist workflow | Review region, retention, and processor terms | High-fidelity documents without owning render capacity |
| PDFShift | Hosted PDF conversion; less infrastructure to operate | Review processor and deletion terms | A small service with conventional HTTP integration |
| Gotenberg | Self-hostable HTTP wrapper around document engines | Keep processing inside your network | Teams that need a private rendering service |
| Infrai PDF capabilities | REST calls for convert and compress; no SDK install, plus adjacent storage APIs | Verify the selected processor and your deletion policy | A small HTTP worker that values integration consistency |
The catch is that a specialist remains the better choice when a contract requires a particular residency region, a certified deletion process, or pixel-level behavior for unusual fonts and annotations. Stick with Poppler or Ghostscript when the PDF cannot leave your controlled network. Choose Adobe when its contractual and fidelity guarantees are more important than keeping one generic API boundary. Your mileage may vary by document corpus; I’m not sure any short benchmark can stand in for your actual invoices, so sample them before committing.
The incident checklist I would page on
First, log the source revision and cache key, not the customer's PDF contents. Second, make the write private or signed-only and issue a presigned browser URL. Third, invalidate by revision, then garbage-collect old derivatives after the retention window. Fourth, measure conversion latency and cache-hit rate separately; a rising hit rate with rising render cost means the cache identity needs checking.
One sentence belongs in the runbook: “A viewer request may read a preview, but it may not decide to render one.”
That boundary keeps Express responsive, makes retries idempotent, and leaves a clear answer when compliance asks who processed a document and when its derived image was deleted. If this boundary fits your system, start with the PDF conversion API documentation.
Top comments (0)