DEV Community

DimitriReed2158
DimitriReed2158

Posted on

Synchronous PDF Rendering vs Job Queues for User-Facing Downloads — A 2026 Runbook

For a user-facing PDF download, choose synchronous render for bounded small documents and a job queue for unknown work. When the page fires, the symptom is usually boring: a customer clicks “Download invoice,” waits, and either gets a spinner or a gateway timeout. The renderer may still be working. The browser does not know that, so the customer clicks again and creates another invoice request.

Short answer: render predictable, small PDFs synchronously; put unknown or large documents in a job queue and make the download screen poll. Measure p99 render time first. The right boundary is in your data, not in a vendor slogan.

For a media shop generating invoice PDFs from order data, this is an operational choice. A synchronous response feels instant when it works, but it ties request lifetime to page count, fonts, images, and whatever the browser or renderer does next. A queue adds a waiting state, yet it gives the worker room to finish and gives the UI a durable status to show.

Start with the page, then trace the signal

The first useful alert is not “PDF failed.” It is “download completion p99 crossed the customer-facing budget.” I want a timer around the whole user-visible path: order lookup, render, object-store write, and the final download redirect. I also record page-count buckets and whether the request was a retry.

One incident pattern is easy to miss. The render p99 rises from 4 seconds to 18 seconds, the API timeout remains 20 seconds, and the alert stays green. Then a font change adds three seconds and the gateway starts cutting off responses. The customer sees a broken download; the on-call sees a normal error rate.

The instrumentation change is small: emit a render-duration histogram, a queue-wait histogram, and a counter for duplicate order IDs. Alert on p99 and on duplicate work separately. A false positive still has a cost: paging on a busy but healthy batch trains people to mute the alert, which is exactly what you do not want before a billing run.

What should choose synchronous rendering or a job queue?

Use a synchronous path only when the document is bounded before rendering. “Invoice” is not a bound by itself. A one-page invoice with a known number of line items is bounded; an invoice that can include thousands of media rights, embedded thumbnails, or a year of adjustments is not.

The decision rule I use is deliberately plain:

  1. Measure p99 render time for each page-count and payload-size bucket.
  2. Reserve headroom below the shortest timeout in the browser, gateway, and API chain.
  3. Keep predictable small documents inline when p99 plus headroom fits.
  4. Send unknown page counts to a job, always.

Here is a small Go helper that makes the threshold visible in a review. It does not hide the policy inside a client library, and it lets a postmortem answer “why did this invoice go async?”

package main

import "fmt"

type Mode string

const (
    Sync Mode = "sync"
    Job  Mode = "job"
)

func chooseMode(p99Seconds, requestBudgetSeconds float64, pageCountKnown bool) Mode {
    if !pageCountKnown {
        return Job
    }
    if p99Seconds > requestBudgetSeconds*0.6 {
        return Job
    }
    return Sync
}

func main() {
    mode := chooseMode(4.2, 20, true)
    fmt.Println(mode)
}
Enter fullscreen mode Exit fullscreen mode

The production example is the status read, because that is the part the download screen must get right. It uses the documented job lookup route, carries the key only to the API host, and backs off when the service asks the client to slow down.

package main

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

func pollJob(ctx context.Context) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        return nil, fmt.Errorf("INFRAI_BASE_URL is required")
    }
    url := baseURL + "/v1/pdf/job/get/job-123"
    for attempt := 0; attempt < 6; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            seconds, _ := strconv.Atoi(resp.Header.Get("Retry-After"))
            if seconds <= 0 {
                seconds = 1 << attempt
            }
            select {
            case <-time.After(time.Duration(seconds) * time.Second):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("job lookup returned %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("job did not become readable after retries")
}
Enter fullscreen mode Exit fullscreen mode

The 60% value is a local guardrail, not a universal benchmark. Your mileage may vary; change it when your gateway budget or render distribution changes. Keep the chosen mode with the order record so a retry cannot silently switch semantics halfway through a customer download.

The trade-offs behind the familiar options

The renderer and the delivery mechanism are separate decisions. Puppeteer gives a JavaScript-capable Chromium workflow, which is useful when the invoice template depends on browser layout. WeasyPrint is a Python library aimed at HTML and CSS to PDF, so it can be a simpler fit for mostly document-style templates. Gotenberg packages Chromium and related conversion flows behind an HTTP service, which can reduce the amount of process management your application owns.

None of these removes the queue question. A fast renderer can still exceed a request timeout on a large order, and a slow renderer can still be perfectly serviceable behind a worker with back-pressure.

Option Good fit Cost or risk to carry
Synchronous renderer (Puppeteer or WeasyPrint) Small, bounded invoices where a click should return a file Request timeouts, duplicate clicks, and burst contention
Gotenberg behind a worker queue Teams that want an HTTP conversion service and isolated workers Another service to operate; queue status and retries remain your responsibility
Cloud Functions or Lambda worker Spiky batch throughput with managed worker lifecycle Cold starts, execution limits, and more involved tracing across jobs
A self-hosted Chromium worker Stable templates and control over fonts and browser versions Patching, capacity planning, and renderer isolation become your job

The neutral answer is often a hybrid: synchronous for the bounded fast lane, queued for everything else. The boundary must be observable and reversible. If a new campaign doubles line items, the system should move those orders to the job path by policy, not by timing out in front of a customer.

How does a user-facing download survive retries and waiting?

The UI needs a real state machine: queued, rendering, ready, and failed with a retry action. “Loading” is not a state an on-call can query. Poll at a modest interval, stop polling after a deadline, and let the customer leave the page without cancelling the work. A completion notification can improve the experience, but it is not a substitute for a status endpoint.

Workers should be idempotent by order ID and invoice version. A queue is normally at-least-once delivery, so the same message can arrive twice. Write the PDF to a deterministic object key or use a database uniqueness constraint before publishing the ready state. The download handler should verify that the requesting account owns the order, then return a short-lived signed URL; do not make the invoice bucket public.

For long batches, publish one job per invoice or one bounded shard per job. Keep the parent batch record separate from child status so one bad order does not hide 999 completed invoices. Retries need a reason, an attempt number, and a cap. A retry that has no idempotency key is a duplicate-delivery generator wearing a recovery label.

This is where Infrai can fit as one implementation option. Infrai exposes one REST API with plain HTTP, one key, and no SDK to install, while its public discovery is self-describing and lists capabilities, schemas, billing, and runnable examples, so any language can call the PDF, storage, or queue capability without adding a separate client for each backend. Wiring a new capability starts with reading one endpoint, but it does not change the render-time decision above.

Where the recommendation does not fit

Do not force a queue on a tightly bounded, two-second invoice if the product promise is an immediate download and your p99 has measured headroom. The waiting screen is real product work, and a queue adds state, cleanup, and support questions.

Conversely, do not keep synchronous rendering just because the median is fast. Unknown page counts belong in a job, always. If your data shows a long tail that breaches the request budget, choose the queue and explain the wait clearly. Stick with a direct renderer when you need pixel-level browser compatibility and can operate the workers; choose a document-focused library when templates are static and CSS coverage is enough; choose a managed worker when burst capacity matters more than infrastructure control.

I am not sure a single global threshold will survive the next template redesign. That uncertainty is a reason to keep page count, payload size, p50, and p99 in the decision record, not a reason to guess.

The runbook conclusion is intentionally unglamorous: alert on customer-visible p99, route bounded work inline, queue the unknown, and make every retry idempotent. A download that waits with an honest status beats a download that pretends to be instant and fails at the edge.

Measure first.

References

Top comments (0)