DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Synchronous PDF Rendering vs Job Queues for User-Facing Support Downloads

Short answer: render synchronously when a small support document has a measured, low-variance latency budget; put redaction and PDF generation on a job queue when fidelity checks, burst control, or retries can outlive one HTTP request. In a customer-support system, I default to a queued job for documents containing personal data, while returning a cached, already-validated artifact synchronously when one exists.

The operational constraint is not “async is modern.” It is whether a rendering worker can finish before the download promise expires without competing with ticket traffic. A support agent can wait for a clear status. They cannot safely share a document that rendered quickly but left an email address visible.

The incident pattern: fast downloads, wrong redactions

I model the failure as a bounded production exercise: an agent selects a conversation, asks for a redacted PDF, and the browser waits while the renderer rasterizes pages and checks text spans. A busy afternoon adds a burst of long transcripts. The API workers fill, retries begin, and one response eventually contains a file whose layout is intact but whose footer still contains a phone number.

That is two separate SLOs. Availability and latency describe whether a response arrives; redaction fidelity describes whether the response is safe to share. Treating them as one number makes the wrong trade invisible.

My first guard is an idempotency key made from the conversation revision, redaction policy revision, and output profile. The artifact key includes the same values. If an agent clicks twice, both requests refer to one logical render. A retry can return 409 Conflict for a duplicate submission or the existing artifact; it must not create a second, differently redacted file.

The queue also gives the renderer a lease and a deadline. A worker validates the source revision, applies deterministic replacements, renders, reopens the PDF for checks, and only then marks the artifact ready. A failed validation is a rejected job with an audit record, not a downloadable half-result.

The part that tends to get skipped is the handoff between those steps. The API must snapshot the exact conversation revision before enqueueing; otherwise an agent can edit a message while a worker is reading it and the audit trail will describe a different document from the one that was shared. The redaction policy is data too, so a policy change should produce a new key rather than mutate an old artifact. During rendering, keep the original source out of logs, cap image dimensions before decoding, and pass cancellation through every library call that accepts a context. After rendering, inspect both the visible page and the extracted text layer, because a black rectangle over an email address is not evidence that the address was removed from selectable text. Store the validator result beside the artifact digest, then issue a short-lived download authorization that names that digest. I've found this sequence makes retries boring: a worker can stop after any step, resume from the durable record, and still produce one auditable outcome. It also makes capacity planning honest, since queue age reflects actual waiting work instead of requests that are merely stuck open.

Small detail, big consequence.

How should synchronous PDF rendering and a job queue handle user-facing downloads?

Synchronous rendering is the better contract for a short, predictable document. The request can return PDF bytes and a content type in one round trip, which keeps the support console simple and makes cancellation obvious. It is also easier to reason about when the renderer is warm and the document is already cached.

The cost is shared capacity. A 200-page transcript, an embedded image, or a slow font path can hold an API worker while other agents load tickets. A timeout does not cancel every downstream operation; without an explicit context deadline, the renderer may continue consuming CPU after the browser has gone away.

A queue changes the user experience but isolates that work. The API accepts a render request, returns a status identifier, and lets a worker claim it with a lease. The console polls or receives an update, then downloads a completed artifact from durable storage. Backpressure is visible as queue age instead of hidden as a pile of blocked HTTP connections.

Here is the boundary I want tested. The interfaces are deliberately generic so the renderer and queue can be replaced without changing the redaction policy.

package redactpdf

import (
    "context"
    "fmt"
)

type Job struct {
    ID             string
    SourceRevision string
    PolicyRevision string
    ArtifactKey    string
}

type Renderer interface {
    Render(ctx context.Context, sourceRevision, policyRevision string) ([]byte, error)
}

type Store interface {
    Exists(ctx context.Context, key string) (bool, error)
    PutIfAbsent(ctx context.Context, key string, data []byte) error
    MarkReady(ctx context.Context, jobID string) error
}

func Process(ctx context.Context, job Job, renderer Renderer, store Store) error {
    exists, err := store.Exists(ctx, job.ArtifactKey)
    if err != nil {
        return err
    }
    if exists {
        return store.MarkReady(ctx, job.ID)
    }

    pdf, err := renderer.Render(ctx, job.SourceRevision, job.PolicyRevision)
    if err != nil {
        return fmt.Errorf("render job %s: %w", job.ID, err)
    }
    if err := store.PutIfAbsent(ctx, job.ArtifactKey, pdf); err != nil {
        return fmt.Errorf("store artifact %s: %w", job.ArtifactKey, err)
    }
    return store.MarkReady(ctx, job.ID)
}
Enter fullscreen mode Exit fullscreen mode

PutIfAbsent is the important boundary. A worker can lose its lease after writing; the retry must observe the same artifact rather than publish a second version. In either architecture, redaction rules should be versioned and the final PDF should be inspected, because a text-layer replacement can miss content embedded in an image.

What failure modes expose the fidelity-versus-render-cost trade-off?

Measure by document shape, not by an average. Track p50, p95, and p99 render time by page count, image count, and redaction rule. Include queue wait, render time, validation time, storage write time, and user download time as separate spans. OpenTelemetry’s trace model is useful here because one trace can connect the agent request to the worker attempt and artifact download.

Then spend test capacity on ugly inputs: a 10,000-line transcript, a scanned screenshot, missing glyphs, a malformed image, and a policy revision arriving during rendering. Set hard limits on input bytes, pages, and wall-clock time. Kill a worker midway, let its lease expire, and verify one artifact, one audit event, and a retry that does not repeat external notifications.

PDF conformance is a separate concern from scheduling. ISO 32000-2 defines the PDF specification; it does not define your queue semantics or prove that personal data was removed. Keep a validator in the pipeline and retain the source revision, policy revision, validator result, and artifact digest in the audit record.

Do not make the queue a substitute for capacity planning. A queue absorbs a burst only until its oldest job violates the support SLO. Set concurrency from renderer CPU and memory limits, reserve capacity for interactive ticket work, and alert on oldest job age plus validation failures. A single pdf_latency histogram cannot tell a saturated queue from a slow renderer.

A decision table for support document downloads

Condition Synchronous path Queued path
One short, cached conversation Direct bytes; simplest user flow Extra status round trip is unnecessary
Long transcript or many images Risks timeout and worker starvation Bounds concurrency and makes waiting explicit
Redaction policy changes often Recompute inside request budget Versioned jobs allow safe reprocessing
Agent needs an immediate download Good when p99 is measured and bounded Return a ready cached artifact, otherwise show status
Burst export or batch sharing Competes with ticket traffic Natural admission control and retry handling
Strict audit and duplicate avoidance Requires careful request keys Job and artifact keys make retries routine

The catch is that a queue is not suitable when the product cannot explain a delayed download, when agents work offline, or when there is no durable state store. Stick with synchronous rendering for a small, low-variance workload with a tested timeout budget. Use a queue when isolation and fidelity checks outweigh an extra status step, and expose the status instead of hiding it behind an endless spinner.

The operating rule I would ship

Start with a synchronous fast path for an existing, validated artifact. On a cache miss, submit an idempotent job and return a status resource; never render an unbounded document while holding the download request open. This hybrid keeps repeat downloads quick while giving the expensive path a lease, backpressure, and auditability.

I am not sure one queue-age threshold will survive every support campaign. Your mileage may vary. Revisit it after a real export and tie the alert to the customer promise, such as “a redacted document is downloadable within N minutes,” rather than to a worker being busy.

The decision is therefore a policy, not a permanent infrastructure choice: synchronous for bounded work, queued for variable or bursty work, and validation before readiness in both cases.

References

Top comments (0)