DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

Go PDF Endpoints for Fillable Tax Forms in US/EU SaaS (and Node.js Queue Limits)

Short answer: use PDF endpoints that accept an idempotent batch, put a durable queue behind the Node.js edge, and let bounded Go workers fill and flatten the forms. For a US/EU SaaS handling fillable tax forms, choose the endpoint contract by its worst queue age and deletion lag, then prove visual fidelity on a golden corpus before increasing concurrency. The fastest single request is irrelevant if 40,000 PDFs cannot finish inside the filing-window SLO.

The operational signal is usually quiet: HTTP 202 responses stay healthy while the oldest job gets older. A B2B SaaS needs one completion clock, one privacy clock, and one retention clock; request latency alone cannot represent any of them.

Define the batch envelope before the endpoint

An intake request should carry a tenant identifier, form revision, tax year, processing region, item count, and an idempotency key. Put taxpayer values in an encrypted body or object, never in query parameters, access logs, or tracing attributes. The metadata is enough to answer “which revision ran where?” without making a trace into a tax record.

Admission control is a capacity decision. Suppose eight workers spend 700 ms of CPU per form: the theoretical ceiling is about 11 forms per second. Validation, storage, network, and retries reduce it, so this number is a planning input, not a throughput guarantee. Set a maximum batch size and reject or defer work when the estimated drain time would break the completion SLO.

Measure the queue.

The queue consumer should acknowledge a message only after a validated PDF is durable and its success event is recorded. A lease must exceed measured p99 render time plus validation headroom; otherwise a slow item can be claimed twice. Keep states explicit: accepted, queued, rendering, validated, published, expired, and deleted. Malformed input is terminal. Capacity delay is retryable. In a 40,000-form run, that distinction changes the operator's next move: a bad field should go to a quarantine report with a customer-visible reason, while a full queue should slow admission and preserve the job for another lease. If both are represented as generic failures, an automatic retry can spend the whole filing window replaying an impossible input, and a manual purge can erase valid work that was merely waiting for capacity. Record the transition, actor, region, and timestamp for every state change so an auditor can reconstruct the decision without opening the tax payload.

What should US/EU SaaS PDF endpoints guarantee for fidelity, latency, privacy, and retention?

Treat these as separate acceptance tracks. Fidelity covers field coordinates, fonts, page count, checkbox state, and whether flattening removes editability. Latency is queue wait plus render time, reported at p50, p95, and p99. Privacy records which service principal can read each byte and in which region. Retention includes source objects, temporary renders, final PDFs, queue messages, dead-letter copies, backups, and observability exports.

For every template revision, keep a golden corpus: a blank form, a long legal name, a non-ASCII address, an empty optional field, a checked box, and text that wraps at a boundary. Compare page images, page count, extracted text, and PDF object structure in staging. A screenshot can look right while interactive fields remain editable, so flattening needs an assertion of its own.

The browser boundary is easy to misunderstand. A JavaScript Blob is immutable, file-like binary data; it is useful for a short-lived upload or download, but it is not a retention policy. Send it over TLS to the intake service, discard browser references after transfer, and issue a short-expiry download URL only after publication passes validation.

Build a narrow Go worker behind the Node.js edge

Node.js can handle authentication, request shaping, and queue admission. It should not decide how many PDF processes run. Keep a small renderer interface so a worker can call a local process or an internal HTTP adapter without changing the queue contract.

package main

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "time"
)

type Job struct {
    Tenant string
    Form   string
    Year   int
    Region string
    Source []byte
}

type Renderer interface {
    FillAndFlatten(context.Context, Job) ([]byte, error)
}

func idempotencyKey(j Job) string {
    h := sha256.Sum256(j.Source)
    return fmt.Sprintf("%s/%s/%d/%s", j.Tenant, j.Form, j.Year, hex.EncodeToString(h[:]))
}

func process(ctx context.Context, r Renderer, j Job) error {
    key := idempotencyKey(j)
    if alreadyPublished(key) {
        return nil
    }

    lease, err := claimLease(ctx, key, 2*time.Minute)
    if err != nil {
        return err
    }
    defer lease.Release()

    pdf, err := r.FillAndFlatten(ctx, j)
    if err != nil {
        return recordFailure(key, classify(err))
    }
    if err := validateFlattened(pdf); err != nil {
        return quarantine(key, err)
    }
    return publishAtomically(ctx, key, pdf)
}
Enter fullscreen mode Exit fullscreen mode

The hash-derived key makes retries converge on one customer-visible artifact. publishAtomically should write to a new object and then commit a pointer, so a retry never exposes a partial file. Keep the worker pool bounded by CPU and memory, and use a separate limit for outbound storage calls; rendering eight forms at once does not help if the object store accepts only four concurrent writes.

Log the opaque job key, template revision, region, duration, outcome, and reason code. Do not log source bytes, field values, bearer tokens, or presigned URLs. Cancellation must execute the same temporary-object cleanup as success, because a timeout otherwise creates a second retention path that happy-path tests miss.

Verify the failure modes, then roll back safely

Run load tests with realistic mixtures, not a single average form. Include a large batch, a malformed field, a slow render, a worker restart, a duplicate delivery, and an object-store timeout. Watch oldest-job age, unclaimed messages, lease expirations, render p95, output-write latency, and deletion lag together. An alert on two signals for ten minutes is more actionable than a red average-latency chart.

For privacy, make region an immutable job attribute and reject cross-region reads by policy. Encrypt source and output objects with separate keys where feasible; restrict operators to metadata by default. A deletion test should create every copy, advance a controlled clock, and verify that the source, temporary file, final object, queue record, backup marker, and telemetry export all disappear or become irreversibly inaccessible within the stated retention window.

Rollouts need a boring escape hatch. Drain new admissions, let in-flight leases finish, and switch the renderer revision for queued work. Keep the prior template revision available until its queue is empty and its retention obligations are met. If fidelity comparisons fail, quarantine the outputs and stop publication; do not silently substitute a different form revision.

The catch is that this design carries operational surface area: queue leases, worker images, PDF libraries, key rotation, and deletion audits all become your team's responsibility. It is not suitable when batches are tiny, tax data cannot leave a strictly controlled execution boundary, or there is no on-call capacity for a renderer pool. In those cases, stick with a simpler in-process path or a managed endpoint whose residency, retention, and audit terms you can verify contractually. Your mileage may vary because template complexity and regional policy change the measured envelope.

References

Top comments (0)