The decision: use an asynchronous PDF endpoint for marketplace bundle merges and splits, keep a synchronous path only for small interactive previews, and reserve a self-hosted renderer for workloads whose fidelity rules or data controls require it. Password-protected customer files turn render time into only one part of latency. Queue delay, password validation, object transfer, retries, and regional placement can dominate once traffic arrives in bursts.
Start with this field guide. Treat every threshold as a value to measure with your own documents, not a universal constant.
| Path | Pick this when | Fidelity control | Latency under load | Operational burden |
|---|---|---|---|---|
| Synchronous managed endpoint | The user is waiting and the input is deliberately bounded | Defined by the endpoint contract | Direct, but vulnerable to concurrency spikes | Low until timeouts and retries need coordination |
| Queued managed endpoint | Merge or split jobs vary in size, page count, or encryption work | Defined by the endpoint contract and acceptance tests | Predictable once admission control protects workers | Medium: queue, state, storage, and callbacks |
| Self-hosted renderer behind a queue | Fonts, forms, signatures, or deployment controls need close ownership | Highest potential control, with your team owning verification | Depends on worker isolation and capacity planning | High: patching, sandboxing, scaling, and regional operations |
Default to the queued path. It gives a US/EU SaaS one place to absorb bursts without pretending a 2-page split and a 400-page merge are the same request. The exception is important: a queue is not suitable when the product interaction requires an immediate preview and the input is strictly capped. Keep that narrow work synchronous.
How should a US/EU SaaS balance PDF endpoint fidelity and latency under load?
Separate the decision into two budgets. The fidelity budget says what may change: page order, dimensions, embedded fonts, forms, annotations, metadata, links, encryption, and visual output. The latency budget says how long each phase may consume: upload, queue, decrypt, inspect, render, encrypt, store, and notify. A single end-to-end timer hides the cause of a slow job.
The useful diagram in words is: browser Blob to regional object storage; job record to a regional queue; isolated worker to a PDF adapter; encrypted result back to object storage; status event to the application. The document bytes don't need to ride through every application process. In browser code, Blob provides an immutable file-like object and can be created from file data; use it as the handoff unit, then release any temporary object URL when the preview is gone.
A marketplace bundle makes the fidelity test concrete. Imagine a seller packet containing a cover sheet, a password-protected invoice, and a return label. A merge can look visually correct while silently losing a form field or changing a page box. A split can preserve pixels yet break the business mapping between order ID and page range. So the acceptance fixture must assert structure and business identity as well as appearance.
Latency needs the same precision. Report queue wait separately from execution, and group results by operation, input bytes, page-count band, encryption state, and region. Percentiles without those dimensions can flatter the common tiny file while hiding the bundle that support actually hears about. Watch saturation too — this distinction matters during a burst — because queue age and active worker count usually explain more than another aggregate request-duration chart.
Don't publish a universal latency promise from a laptop test. Traffic shape, customer PDFs, and the selected engine decide the result; uncertainty is resolved by replaying a sanitized corpus at the concurrency and regional mix you expect.
Pick synchronous processing for bounded interactive work
A synchronous endpoint is a good fit for a small preview or a validation step whose maximum input size and work are enforced before rendering. The browser submits a Blob, the application validates declared metadata, and the service returns within the interaction's latency budget.
Short path. Easy mental model.
The catch is coupling. The caller, application connection, renderer capacity, and downstream storage all occupy the same failure window. Retrying after a client timeout may also duplicate expensive work unless the request carries an idempotency key and the server persists its result mapping. Under load, reject excess work deliberately and tell the caller when to retry; don't let an unbounded in-memory backlog turn every request slow.
This path is not suitable for arbitrary customer bundles. Switch to a queued job when page count, file size, password validation, or render complexity can vary materially, or when a merge must survive a browser closing. The boundary should be a product rule with telemetry, not a hopeful timeout value.
Pick queued processing for variable customer bundles
A queued endpoint accepts metadata, stores the source separately, and returns a stable job identifier. Workers claim jobs only when they have capacity. That distinction matters at peak traffic: admission remains quick while completion time rises in a way you can measure and communicate. It also lets EU input stay in an EU processing lane and US input stay in a US lane when that placement is part of your system policy.
Queue it first.
Use a job state machine such as accepted, running, succeeded, rejected, and failed. Rejected means the input violated a published constraint, while failed means processing did not produce a result; keeping those states apart prevents an invalid password or oversized bundle from looking like renderer instability. Never put the password in logs, job names, URLs, metrics labels, or exception text. Pass it through a secret-bearing execution channel with the shortest practical lifetime, and erase the reference when the job reaches a terminal state.
The operational cost is real. You now own queue retention, duplicate delivery handling, result expiry, cancellation semantics, and a status surface. This is still the better default for merge and split because those controls make overload visible. If your volume is tiny and all documents are tightly bounded, the queue may add more machinery than value; stick with synchronous processing until measurements show contention.
Implement a load-aware TypeScript worker
Keep the PDF engine behind a narrow adapter. This preserves the same job contract if a managed endpoint, an SDK, or a self-hosted process later wins your fidelity tests. It also gives tests a clean point to inject known outputs without teaching application code engine-specific options.
interface BundleJob {
id: string;
region: "us" | "eu";
operation: "merge" | "split";
sourceKeys: string[];
passwordRef: string;
outputKey: string;
pageRanges?: Array<{ start: number; end: number }>;
}
interface PdfAdapter {
merge(files: Uint8Array[], password: string): Promise<Uint8Array>;
split(
file: Uint8Array,
ranges: Array<{ start: number; end: number }>,
password: string,
): Promise<Uint8Array>;
}
interface JobTelemetry {
timing(name: string, milliseconds: number, labels: Record<string, string>): void;
increment(name: string, labels: Record<string, string>): void;
}
The worker below measures phases instead of recording one opaque duration. Storage, secrets, and state are interfaces supplied by your platform. The labels are intentionally low-cardinality; a job ID belongs in correlated logs or traces, not in metric labels.
type Services = {
pdf: PdfAdapter;
telemetry: JobTelemetry;
loadObject(key: string, region: BundleJob["region"]): Promise<Uint8Array>;
saveObject(key: string, bytes: Uint8Array, region: BundleJob["region"]): Promise<void>;
readSecret(ref: string): Promise<string>;
deleteSecret(ref: string): Promise<void>;
markSucceeded(id: string, outputKey: string): Promise<void>;
markFailed(id: string, reason: "input_rejected" | "processing_failed"): Promise<void>;
};
async function timed<T>(
name: string,
labels: Record<string, string>,
telemetry: JobTelemetry,
work: () => Promise<T>,
): Promise<T> {
const started = performance.now();
try {
return await work();
} finally {
telemetry.timing(name, performance.now() - started, labels);
}
}
async function processBundle(job: BundleJob, services: Services): Promise<void> {
const labels = { operation: job.operation, region: job.region };
let password = "";
try {
password = await services.readSecret(job.passwordRef);
const files = await timed("bundle_input_load_ms", labels, services.telemetry, () =>
Promise.all(job.sourceKeys.map((key) => services.loadObject(key, job.region))),
);
const result = await timed("bundle_render_ms", labels, services.telemetry, () => {
if (job.operation === "merge") {
return services.pdf.merge(files, password);
}
if (files.length !== 1 || !job.pageRanges) {
throw new Error("invalid_split_input");
}
return services.pdf.split(files[0], job.pageRanges, password);
});
await timed("bundle_output_save_ms", labels, services.telemetry, () =>
services.saveObject(job.outputKey, result, job.region),
);
await services.markSucceeded(job.id, job.outputKey);
services.telemetry.increment("bundle_jobs_total", { ...labels, result: "succeeded" });
} catch (error) {
const rejected = error instanceof Error && error.message === "invalid_split_input";
await services.markFailed(job.id, rejected ? "input_rejected" : "processing_failed");
services.telemetry.increment("bundle_jobs_total", {
...labels,
result: rejected ? "rejected" : "failed",
});
} finally {
password = "";
await services.deleteSecret(job.passwordRef);
}
}
The before/after is crisp. Before, request_duration rises and nobody knows whether the queue, decryption, rendering, or storage caused it. After, each phase has a timer, the job result has a bounded label, and the password never becomes telemetry. Add queue-age and worker-saturation gauges outside this function, because a job cannot measure the time before it is claimed.
Load testing should replay the mix, not only the mean. Build a sanitized fixture matrix across merge and split, protected and unprotected input, representative page bands, and both processing regions. Increase arrival rate until queue age no longer returns to baseline, then decide whether to add workers, lower admission limits, or move a document class to a different execution path. Fidelity fixtures run on every adapter change; load tests run before capacity changes and with production-like storage distance.
Know the limits before choosing an endpoint
No single path wins all three axes. Managed synchronous processing minimizes moving parts but is a poor match for long, variable jobs. Queued managed processing controls bursts but adds state and delayed-result UX. Self-hosting offers more control over the execution environment and fidelity investigation, while making sandboxing, upgrades, capacity, and two-region operation your responsibility.
Password support is only an entry criterion. Before selecting any endpoint, test how its documented contract treats encrypted input and output, malformed files, form fields, annotations, embedded fonts, signatures, page boxes, and metadata. Then verify cancellation, duplicate requests, maximum accepted work, data location, retention, and deletion behavior against your own requirements. If a signature must remain valid after transformation, or if a particular PDF feature is contractually untouchable, a merge may be the wrong operation regardless of vendor.
The final choice should come from two artifacts: a fidelity corpus with explicit pass/fail assertions and a load report that separates queue wait from work time. Everything else is brochure comparison.
Top comments (0)