When you click "Merge PDF" on most websites, the file you just selected is uploaded to a server, processed there, and then either sent back as a download or held until you click a second button. The actual merge takes a fraction of a second; the upload and download are what you wait on.
We do it differently. The merge runs in your browser. No file leaves your device.
The pieces
- pdf-lib — pure JavaScript PDF manipulation. Open, copy pages, write back out. Works in browsers and in Node. Strikingly compact (~120 KB gzipped) for what it does.
- A Web Worker — pdf-lib runs off the main thread, so the UI never freezes during a merge or save. We wrap the worker with Comlink so the calling code looks like a normal async function.
- OPFS — for very large files (gigabyte-scale PDFs do exist), we stage the bytes in the Origin Private File System rather than holding them in RAM.
createImageBitmap+OffscreenCanvas— image conversion (PNG/JPG/WebP/AVIF) uses the browser's built-in codecs. No JS image-decoding library needed; the browser already has one with hardware acceleration.
A merge in 25 lines
import { PDFDocument } from 'pdf-lib';
export async function merge(files: File[]): Promise<Blob> {
const merged = await PDFDocument.create();
for (const f of files) {
const bytes = new Uint8Array(await f.arrayBuffer());
const src = await PDFDocument.load(bytes, { ignoreEncryption: true });
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach((p) => merged.addPage(p));
}
return new Blob([await merged.save()], { type: 'application/pdf' });
}
That's the whole thing. Wrapped in a worker, exposed through Comlink, called from a React
component. The result is an ObjectURL you can plug straight into an <a download> link.
When client-side stops working
Three failure modes push us back to the server:
- OCR, transcription, AI image work. Tesseract.js exists and is competent for short English text, but for production-quality multilingual OCR you really want full Tesseract + ocrmypdf, plus the option to fall back to a vision model. Same story for Whisper. These live on our GPU pool.
- PDF compression. It's possible in WASM (we tried). It's also slow and the resulting files are larger than what Ghostscript produces. Server-side wins.
- Files larger than ~1.5 GB. Modern browsers can handle them, but most users on most machines run out of memory long before that.
For everything else, in-browser is faster, cheaper, and more private — three things that don't usually line up. WebAssembly is the rare technology where they do.
If you want to look at the code, the WASM dispatcher and handlers live in
packages/wasm-runtime.