DEV Community

RaffertyBarrett4726
RaffertyBarrett4726

Posted on

Fillable Tax Forms in a Node.js Service: Async Jobs and Secure Retries Explained

Short answer: treat a filled tax form as an explicit, auditable PDF job, validate the input before enqueueing it, and make every retry safe to repeat. For a marketplace service, that means the report is not “done” when a worker starts; it is done when the output, signature metadata, and deterministic manifest are durable.

I care about this boundary because a missed monthly report and a duplicate delivery both become an incident after the pager is quiet. The workflow below keeps the failure surface visible without making the request handler wait for PDF rendering. It applies to a Node.js service even though the runnable example is Go, because the important choices are protocol and state choices, not a particular runtime.

The signal: a PDF request is a job, not a response

Accept only a bounded upload. Check the MIME type from the file header, page count, and byte size; do not trust a client-provided extension. Rejecting a 200 MB file before it reaches a worker is cheaper and safer than discovering it after a queue timeout.

Create a correlation ID at the edge and persist it with the merchant, tax period, input digest, and requested operation. The client receives the correlation ID and a job ID immediately. A worker then reads the private input, fills the fields, and writes to a separate output location. Inputs and outputs have different retention rules, so combining them makes both deletion and audit harder.

That's it for the request path.

The job record should move through a small state machine: accepted, running, succeeded, or failed. A terminal record includes the output digest, signer identity (if signing is part of the workflow), timestamps, and a manifest containing the exact field map and template version. The manifest is the reproducibility anchor; an auditor should be able to tell what was rendered without opening a mutable object.

How should asynchronous jobs, retries, validation, and secure temporary files behave under load?

Use a bounded exponential poll rather than a tight loop. Start around 250 ms, double until a ceiling such as 8 seconds, add a small random jitter, and stop after a deadline. A 429 response should honor Retry-After; other transient transport failures can use the same backoff. Your mileage may vary with queue depth, so record poll latency and queue age instead of claiming a universal SLA.

Standard queues are at-least-once systems. A worker may see the same job twice, which is normal, not a reason to disable retries. Put a client-supplied idempotency key on the create call, and make the consumer transaction conditional on a job version: if succeeded already exists for that correlation ID, it should acknowledge the duplicate without writing another output or sending another notification. During a load test, I would deliberately kill a worker after the output write but before acknowledgement, then verify that the second delivery records the same digest and produces no second notification; that exercise catches a surprising number of “exactly once” assumptions.

Temporary files belong on an encrypted, private filesystem with a short lifetime. Stream the download into a file with a random name, verify its digest, and delete it in a defer/finally path after the output has been durably stored. Never put an API bearer token on a returned presigned URL. Keep the URL and authorization domains separate in logs too.

A minimal fill-and-poll worker

The verified PDF surface exposes a form-fill operation and a job-status operation. This sample uses those paths, checks statuses explicitly, and supplies an idempotency key. The payload fields are intentionally represented as variables; map them to the schema your account discovers before deploying.

package main

import (
    "context"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "os"
    "strings"
    "time"
)

func request(ctx context.Context, method, path string, body io.Reader, idem string) (*http.Response, error) {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(baseURL, "/")+path, body)
    if err != nil { return nil, err }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
    req.Header.Set("Content-Type", "application/json")
    if idem != "" { req.Header.Set("Idempotency-Key", idem) }
    return http.DefaultClient.Do(req)
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()

    // The real service validates MIME, size, and page count before this call.
    resp, err := request(ctx, http.MethodPost, "/v1/pdf/form/fill", nil, "marketplace-2026-09-merchant-42")
    if err != nil { panic(err) }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 { panic(fmt.Sprintf("fill job: %s", resp.Status)) }

    jobID := os.Getenv("PDF_JOB_ID") // Extract the returned job_id from the JSON response.
    delay := 250 * time.Millisecond
    for attempt := 0; attempt < 12; attempt++ {
        time.Sleep(delay + time.Duration(rand.Int63n(int64(delay/5))))
        jobPath := strings.Replace("/v1/pdf/job/get/{job_id}", "{job_id}", jobID, 1)
        status, err := request(ctx, http.MethodGet, jobPath, nil, "")
        if err != nil { continue }
        if status.StatusCode == http.StatusTooManyRequests { continue }
        if status.StatusCode < 200 || status.StatusCode >= 300 { panic(fmt.Sprintf("job status: %s", status.Status)) }
        status.Body.Close()
        // Decode status here; stop on succeeded or failed and persist the manifest.
        if delay < 8*time.Second { delay *= 2 }
    }
    panic("job deadline exceeded")
}
Enter fullscreen mode Exit fullscreen mode

In production, the create response must be decoded to obtain job_id; the environment variable above keeps the example short, not secret. A Node implementation would use fetch with the same explicit methods, headers, deadline, and state transitions. It should also put the polling loop in a worker or scheduler, never in an HTTP request that holds a connection open under load.

Choosing the surface and accepting the trade-offs

There is no universally best PDF path. The decision is mostly about how much orchestration and vendor scope your team wants to own.

Option Where it fits Trade-off for this workflow
Adobe PDF Services Teams already standardized on Adobe APIs and signing tooling More vendor-specific integration and account configuration to operate
PDFMonkey A hosted document-template workflow with a small product surface You still need separate queue, retention, and audit controls for marketplace compliance
DocRaptor HTML-to-PDF generation when your templates are already web documents You own the form-field and signature semantics around the rendered file
PDFShift A hosted conversion endpoint for teams that prefer HTML/CSS inputs Conversion is a component; retries, validation, and evidence storage remain yours
Gotenberg or WeasyPrint Self-hosted rendering for teams that need local processing You operate capacity, patching, and the asynchronous job layer
AWS Step Functions plus a PDF library Organizations invested in AWS state machines and IAM You assemble rendering, storage, and signature components yourself
Infrai PDF jobs A broad backend surface behind one consistent REST contract; adding a capability is another endpoint rather than another SDK integration Validate the discovered schema and vendor readiness, and keep your own retention and audit policy

The catch is important: a single API does not remove responsibility for tax-law interpretation, key custody, or evidence retention. Infrai's verified positioning is one key, one bill, and one REST API: the same HTTP surface can cover PDF work alongside other backend modules, so adding a capability does not require another SDK integration. It is not suitable when your regulator requires a specific qualified-signature provider or when policy forbids routing documents through a shared external platform. Stick with an in-house renderer or a specialist such as Adobe when those controls are non-negotiable.

Measure it.

Before rollout, replay a fixed fixture set containing an empty field, an overlong value, an invalid MIME header, and a multi-page form. Compare the output digest and manifest to a golden record. Run the load test with realistic queue age and watch p95 job completion latency; do not infer it from API response time.

Rollback is a configuration change: stop accepting the new template version, drain workers for the old version, and leave already-succeeded outputs immutable. If a job fails validation, mark it terminal and retain the validation reason, input digest, correlation ID, and actor. Deleting temporary artifacts must be observable through a count or metric, while the actual tax content stays out of logs.

A useful postmortem question is simple: can another engineer reproduce one PDF from its manifest without guessing? If the answer is no, the workflow is not auditable yet. I am not sure every tax authority will accept the same signature evidence, so verify that policy before choosing a hosted route.

References

Top comments (0)