Short answer: put legal contract review behind a durable asynchronous job boundary, validate before enqueueing, make each stage idempotent, and treat the original bytes plus every transformation as an append-only evidence chain. For an edtech service that merges and splits signed document bundles, that design protects the signature and audit trail without forcing an HTTP request to wait for the slowest review.
| Design | Signature and audit trail | Latency under load | Operational cost |
|---|---|---|---|
| Durable queue plus isolated workers | Immutable input, recorded transforms, separate outputs | Admission stays short; queue age exposes saturation | More moving parts and explicit cleanup |
| In-process background task | Audit events share one process lifecycle | Looks fast until restarts or bursts | Low initial setup, high recovery risk |
| Synchronous request | Simple correlation for tiny files | Tail latency includes every review stage | Easy to start, poor fit for variable work |
The recommendation is the first row. The catch is real: a durable queue is not suitable when every review is tiny, disposable, and completed inside a tightly bounded request. In that narrow case, stick with synchronous processing and keep the audit record in the same transaction. Don't build a queue because queues sound serious. Build one when work can outlive the request.
How should a Node.js service run asynchronous legal contract review under load?
Split the workflow into admission, execution, and publication. Admission authenticates the caller, applies size and type policy, computes a digest over the received bytes, creates a job record, and enqueues only the job ID. It does not parse twenty attachments while the client connection is open. Execution claims that ID, reads immutable input, performs deterministic stages, writes new artifacts rather than mutating originals, and appends audit events. Publication exposes only a terminal result whose output digest and lineage are already recorded.
That boundary matters in an edtech contract flow. Consider one district agreement arriving as a four-part bundle: a master agreement, data-processing addendum, signature page, and exhibits. Admission stores the exact bundle and its digest. A split stage produces four new artifact IDs, each linked to that parent digest; validation then records which artifact holds the signature and which policy version evaluated it. After review, a merge stage consumes only the approved artifact IDs and emits another digest. It does not overwrite the upload, move the signature result to the merged file, or erase the rejected branch. An authorized reader can now walk from the delivered bundle back through the merge, each reviewed part, the split, and finally the uploaded bytes. Splitting pages for review and merging approved parts for delivery changes the container, so the service must not pretend that a signature over the original bundle automatically describes a derived file. Preserve the original, record its digest, identify every derived artifact, and record the exact parent-child transformation. Verification and transformation are different stages. Keep them separate.
Use a state machine small enough to inspect: accepted, running, retry_wait, succeeded, rejected, and dead. A worker transition should be conditional on the current state and lease token. That prevents an expired worker from publishing after another worker has reclaimed the job. The queue message is a wake-up hint; the database record is the authority. This distinction also makes duplicate delivery boring rather than dangerous.
A useful idempotency key covers the tenant, operation, input digest, and review-policy version. It should not include a random request ID, because that defeats deduplication. It also should not silently reuse an output after the policy changes. I can't know the correct retention window for a particular contract program; legal and security owners have to set it. The implementation can make that decision visible as policy instead of burying it in worker code.
Signatures and audit trails are separate decisions
A digest answers whether bytes changed. A signature verification step answers whether a particular signature validates under the verification policy used at that time. An audit trail answers who requested work, what policy ran, which inputs and outputs were involved, and how the state moved. Collapsing those into one verified: true flag loses the evidence needed to explain a later result.
Record events as append-only data with an event ID, job ID, tenant ID, event type, actor or service identity, timestamp, input and output digests where relevant, policy version, and a correlation ID. Keep sensitive extracted contract text out of routine logs. The audit record should point to protected artifacts by opaque identifier; authorization is still checked when those artifacts are read.
The ordering rule is easy to miss — and expensive to reconstruct later. Persist the job state change and its audit event atomically, then acknowledge the queue message. If acknowledgment happens first, a crash can hide unfinished work. If state changes without a matching event, the history becomes an inference exercise. Neither outcome is acceptable for a review system whose primary decision axis is evidence.
For bundle operations, model lineage directly:
-
original_digestidentifies the exact uploaded bytes. -
parent_artifact_idsidentifies what a split or merge consumed. -
operationandpolicy_versionexplain how the output was produced. -
output_digestidentifies the derived bytes. -
signature_check_idlinks to the verification result without claiming that a derived artifact inherited the original signature.
No magic flag.
Retries need classification, budgets, and idempotency
Retry only failures that can plausibly change without changing the input. A short-lived dependency timeout can enter retry_wait; malformed document structure should become rejected immediately. Authentication and authorization failures belong at admission, before a job exists. Define application error codes such as REVIEW_INPUT_INVALID, REVIEW_POLICY_REJECTED, and REVIEW_DEPENDENCY_TIMEOUT, then map each code to one explicit retry policy. These are service-owned codes, so their meaning stays stable even if a lower-level library changes its wording.
Use exponential backoff with jitter, a maximum attempt count, and a maximum elapsed retry budget. Persist attempt, next_attempt_at, and the last classified error. A process-local counter vanishes on restart. More important, make each external effect idempotent: artifact writes use a deterministic key or compare-and-set operation, audit events have unique IDs, and final publication requires the active lease. Retrying the whole job is then safe even if the previous worker finished a step but died before acknowledging it.
I first reach for retry counts because they are easy to graph. They aren't enough. Queue age tells you whether capacity is falling behind, while retry counts can rise because one dependency is unhealthy even when workers have spare capacity. Track both, along with admission latency, execution duration by stage, artifact bytes, cleanup failures, terminal outcome, and lease expirations. Do not put contract text, filenames supplied by users, or signature material into metric labels.
A dead-letter state is a diagnostic boundary, not a second queue that nobody owns. Store the classification and evidence needed for an authorized replay. Replays should create a new attempt linked to the same immutable input and a declared policy version; silently editing the old history destroys the point of having one.
A TypeScript worker with secure temporary files
Temporary storage is a capability boundary. Create a unique directory for one job, restrict its permissions, generate server-side filenames, reject size violations before expensive parsing, and remove the directory in finally. Never construct a path from the original filename. Keep only opaque artifact IDs in queue messages.
This example leaves parsing and signing behind interfaces because those implementations depend on the chosen document formats and verification policy. The orchestration is the reusable part:
import { createHash, randomUUID } from "node:crypto";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
type ReviewJob = {
id: string;
tenantId: string;
inputArtifactId: string;
policyVersion: string;
leaseToken: string;
};
type ArtifactStore = {
read(id: string): Promise<Uint8Array>;
putOnce(key: string, bytes: Uint8Array): Promise<string>;
};
type JobStore = {
publishSuccess(input: {
jobId: string;
leaseToken: string;
outputArtifactId: string;
inputDigest: string;
outputDigest: string;
eventId: string;
}): Promise<void>;
};
type Reviewer = {
review(inputPath: string, outputPath: string): Promise<void>;
};
const sha256 = (bytes: Uint8Array): string =>
createHash("sha256").update(bytes).digest("hex");
export async function runReview(
job: ReviewJob,
artifacts: ArtifactStore,
jobs: JobStore,
reviewer: Reviewer,
): Promise<void> {
const directory = await mkdtemp(join(tmpdir(), "contract-review-"));
await chmod(directory, 0o700);
try {
const input = await artifacts.read(job.inputArtifactId);
const inputDigest = sha256(input);
const inputPath = join(directory, "input.bundle");
const outputPath = join(directory, "reviewed.bundle");
await writeFile(inputPath, input, { mode: 0o600, flag: "wx" });
await reviewer.review(inputPath, outputPath);
const output = await readFile(outputPath);
const outputDigest = sha256(output);
const outputKey = [job.tenantId, job.id, job.policyVersion, outputDigest].join("/");
const outputArtifactId = await artifacts.putOnce(outputKey, output);
await jobs.publishSuccess({
jobId: job.id,
leaseToken: job.leaseToken,
outputArtifactId,
inputDigest,
outputDigest,
eventId: randomUUID(),
});
} finally {
await rm(directory, { recursive: true, force: true });
}
}
publishSuccess must conditionally accept the current lease and atomically append the success event. The example's putOnce is also deliberate. A retry may produce the same bytes; it should converge on the same stored artifact instead of multiplying outputs. For very large bundles, replace whole-buffer reads with a streaming implementation and enforce a byte ceiling while reading. The Blob abstraction can represent immutable raw data and expose a stream, but don't confuse that JavaScript object model with a complete storage, authorization, or cleanup policy.
Latency under load is a queueing problem
Benchmark the system at the stage boundaries, not with one end-to-end average. Report p50, p95, and p99 for admission and execution separately, plus queue age at job start. An average can remain calm while a small set of large bundles occupies every worker. Use a workload mix with several file sizes, page counts, signature states, and merge/split fan-outs. Keep the mix fixed in version control so a parser or policy change can be compared against the same test.
Set budgets before running the benchmark. For example, a team might choose an admission p95 below 300 ms and require 99% of ordinary jobs to start within 30 seconds during its declared burst profile. Those are illustrative engineering targets, not universal claims. The useful numbers come from your own workload and hardware. I'm not sure a synthetic generator can reproduce the pathological documents in your corpus; sampling sanitized structural characteristics from production inputs would resolve that uncertainty.
Concurrency should be bounded twice: a worker-level job limit and a stage-level limit for CPU- or memory-heavy parsing. If one job can fan out into forty page operations, a limit of eight jobs is not really eight units of work. Measure peak resident memory and event-loop delay while stepping concurrency upward. Stop increasing it when throughput flattens or tail latency and memory rise sharply. Faster admission is meaningless if it only makes the queue longer.
Backpressure belongs at admission. Enforce tenant quotas and global capacity policy before accepting unlimited bytes, and return a stable overload response with retry guidance when the service cannot safely queue more work. Fair scheduling prevents one large school migration from delaying every small agreement. Separate worker pools can help when signed bundles require substantially different resources from simple splits, but each pool adds configuration and idle-capacity risk. I dislike config bloat; start with measured evidence for one pool, then split only when the latency distribution shows distinct classes.
The runner-up, synchronous processing, is better when inputs are strictly small, the transformation is deterministic and fast, traffic is bounded, and the caller needs the result within one request. An in-process background task is acceptable for noncritical previews that may be lost and recreated. Neither is a sound default for signed legal artifacts that require durable retry and an explainable audit trail.
Ship the queue design only after testing duplicate delivery, worker termination after artifact write, lease expiry during review, disk exhaustion, oversized bundles, invalid signatures, cleanup on every exit path, and replay under a new policy version. One happy-path latency chart proves very little.
Top comments (0)