Engineering

Client-Side PDF Tools: Merge, Split, and Transform Without Uploads

A reference design for browser-side PDF utilities: which library does which job, how to keep the main thread responsive, and the memory and failure limits to plan for.

5 min read

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:

JobExampleTypical approach
Structural editMerge, split, reorder, rotate, set metadataA document-writing library such as pdf-lib
Extraction or readingText search, form-field values, thumbnailsA parser or renderer
Conversion or heavy renderingRasterize pages, recompress imagesA 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:

  1. Structural — remove unused objects, deduplicate resources, use object streams. No visual change.
  2. Image downsampling — re-encode embedded images at a lower resolution or quality. Visible, and irreversible.
  3. 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

  1. Merge, split, and rotate produce output that opens in two independent viewers.
  2. A 200-page file completes without blocking scrolling.
  3. The documented size limit rejects a larger file with a visible message.
  4. An encrypted file prompts for a password instead of throwing.
  5. Text remains selectable after the structural compression pass.
  6. 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.