Most online PDF utilities ask you to upload the file. For a contract, a medical record, or a passport scan, that transfer is the risky part of the interaction — and the server-side compute is what makes the product expensive to run. Moving the work into the browser removes both concerns, at the cost of tighter memory limits and a new set of failure modes.
The design below is a reference architecture for a client-only PDF toolkit. It is not a report of a BuildVault deployment.
Decide what kind of operation you are doing
"PDF tooling" covers three different jobs, and the library choice follows from the job:
| Job | Example | Typical approach |
|---|---|---|
| Structural edit | Merge, split, reorder, rotate, set metadata | A document-writing library such as pdf-lib |
| Extraction or reading | Text search, form-field values, thumbnails | A parser or renderer |
| Conversion or heavy rendering | Rasterize pages, recompress images | A WASM build of a full renderer, in a worker |
Mixing these in one page is where bundles become enormous. Load the heavy renderer only on the route that needs it, with a dynamic import, and keep the default route limited to structural edits.
Keep the main thread free
Parsing a 200-page PDF on the main thread freezes scrolling and animation. Put the work in a worker and move buffers rather than copying them:
// main.ts — hand the file to a worker without duplicating memory
const worker = new Worker(new URL("./pdf.worker.ts", import.meta.url), {
type: "module",
});
export async function merge(files: File[]) {
const buffers = await Promise.all(files.map((file) => file.arrayBuffer()));
const jobId = crypto.randomUUID();
return new Promise<Uint8Array>((resolve, reject) => {
const onMessage = (event: MessageEvent) => {
if (event.data.jobId !== jobId) return;
worker.removeEventListener("message", onMessage);
if (event.data.error) reject(new Error(event.data.error));
else resolve(event.data.bytes);
};
worker.addEventListener("message", onMessage);
// Second argument: these buffers are moved to the worker, not copied.
worker.postMessage({ type: "merge", jobId, buffers }, buffers);
});
}
The second argument to postMessage is the transfer list: those ArrayBuffer objects are moved, not cloned, so a job identifier is needed to match responses to requests when the worker is reused. The catch is that the sending side can no longer read the moved buffers — a detail that causes confusing "detached ArrayBuffer" errors when a preview and a merge share the same buffer. The MDN documentation on transferable objects describes the rules.
Plan for memory ceilings
Browser memory is the binding constraint, not CPU. A few guardrails prevent the worst outcome, which is a tab that dies mid-operation with no error:
- Reject files above a documented size before parsing, and state the limit in the interface.
- Stream or process page ranges in batches instead of building every output page at once.
- Release intermediate documents explicitly; a parser that holds a second full copy of the file doubles the peak.
- Show progress based on pages completed, not a spinner, so a slow file is distinguishable from a hung one.
Test with a file that is clearly too large. The expected result is a clear message, not a crash.
"Compress" needs a definition
Compression means at least three different things in PDF work, and only one of them is lossless:
- Structural — remove unused objects, deduplicate resources, use object streams. No visual change.
- Image downsampling — re-encode embedded images at a lower resolution or quality. Visible, and irreversible.
- Rasterize — flatten entire pages into images. Small and text-unselectable; usually the wrong default.
Offer the structural pass first and describe image downsampling as a quality trade-off. A button labelled "compress" that silently degrades text legibility is a support ticket generator.
Handle the failures you will actually see
- Encrypted files. Many PDFs are encrypted with an empty user password. Detect it and ask, rather than failing with a parse error.
- Malformed structure. Repaired files exist; a parser may accept documents your writer cannot re-emit. Wrap the write step and report which page failed.
- Zero-length or mislabelled files. Check the
%PDF-header rather than trusting the extension. - Interrupted operations. A tab closed mid-merge leaves nothing behind. That is acceptable — but do not present a partial download as complete.
A privacy claim you can defend
If the design is client-only, the claim is simple and testable: the file never leaves the device. Keep it true — no analytics call that includes file names, no "feedback" upload of the document, no preview thumbnail posted to a server. A privacy claim undermined by a single telemetry event is worse than no claim. The same reasoning applies to retrieval systems that index internal documents, covered in hybrid search with citations.
A testing checklist
- Merge, split, and rotate produce output that opens in two independent viewers.
- A 200-page file completes without blocking scrolling.
- The documented size limit rejects a larger file with a visible message.
- An encrypted file prompts for a password instead of throwing.
- Text remains selectable after the structural compression pass.
- Network inspection shows no document content leaving the browser.
Related reading: the general pattern of moving work to the client appears in real-time dashboards over server-sent events, where the server sends updates and the browser does the rendering.