My last post covered running AI in the browser. This one is about the quieter member of the local-first family: PDF processing — merging, splitting, compressing — and what actually happens to the bytes when no server is involved.
The punchline first: a proper PDF merge does not "read" your documents the way a PDF viewer does. It rearranges serialized objects. Understanding that one sentence explains most of the behavior people find surprising in merge tools — and it shows why the whole operation fits comfortably in a browser tab.
A PDF is a database, not a document
Strip the format mystique away and a PDF file is a miniature object database: a header, a trail of objects (pages, fonts, images, streams), a cross-reference table pointing to each object byte offset, and a trailer. "Opening" a PDF means parsing that xref table, loading the page tree, and resolving references. Rendering is just one thing you can do after parsing.
Which means the three classic operations decompose like this:
- Merge = copy the page objects of file B into file A's object space, remap their references, rebuild one xref table
- Split = the mirror image: keep a subset of the page tree, carry over only the objects those pages reference
- Compress (for scanned PDFs) = re-encode each page's embedded image stream (JPEG quality is your dial)
None of that requires rendering a single pixel. That is why our merge tool describes itself as "structural": pages come out byte-identical, text stays selectable, and a vector chart does not degrade, because it was never rasterized in the first place.
What pdf-lib gives you — and what it makes obvious
In the browser this is all pdf-lib, which is pure JavaScript with zero dependencies on native code or workers. The merge core is almost disappointingly small:
const out = await PDFDocument.create();
for (const file of files) {
const src = await PDFDocument.load(await file.arrayBuffer());
const pages = await out.copyPages(src, src.getPageIndices());
pages.forEach(p => out.addPage(p));
}
const bytes = await out.save();
copyPages is where the object-remapping happens. Two things you learn from living with this API:
Page order is list order. There is no implicit sorting, no filename intelligence. If users expect page 10 to land after page 9, your UI has to enforce it — which is why reorder arrows exist and why "list order becomes page order" is the honest description.
What the tool does not preserve is as informative as what it does. Bookmarks that point to page positions shift. Form fields sharing a name across documents can collide — pdf-lib will not deduplicate them for you. Encrypted files need decryption before load or they throw. None of these are bugs in the library; they are the format showing its database nature.
Compression is where honesty matters
The interesting decision in a browser PDF compressor is what to do when compression makes the file bigger.
Scanned PDFs — one big image per page — compress beautifully: re-encode each page image as JPEG at quality 50-65 and 60-90% of the size evaporates, because the 300 DPI scan carries resolution nobody consumes. Vector PDFs generated by Word or LaTeX are already tiny; re-rendering them as images inflates them.
So the honest compressor has three behaviors: compress scans aggressively, and when the output grows, say so (+12% on the button, not a silent download) and point users toward splitting instead. Tools that always claim victory are lying to somebody.
The browser implementation detail that surprised me: canvas-mediated JPEG re-encoding of page images is where the quality dial actually lives, and quality-to-size is emphatically not linear — the useful cliff for scanned documents sits between 50 and 65, where halving the quality costs you single-digit percent of visual fidelity.
The privacy argument, restated for files people actually have
For portrait photos, local processing is a nice property. For PDFs it is closer to the whole point, because the documents that trigger PDF operations are disproportionately the sensitive ones — contracts under negotiation, visa applications, bank statements. The flow "I need to merge two files" should not have a mandatory step of "upload my signed contract to a random web service."
The verification is the same as always: DevTools, Network panel, watch zero requests leave while you work. Our complete guide to the PDF toolkit goes deeper on when to merge vs split vs compress — with the same no-upload guarantee.
What is hard in the browser (an honest list)
- Encrypted PDFs: pdf-lib supports loading some variants but not decrypting all of them; the correct UX is a clear error, not a partial file
- OCR: converting scanned pages back to selectable text needs a heavy model; we shipped without it rather than fake it
- Very large merges: a few hundred pages works; ten thousand stresses mobile Safari memory — structural merging is cheap, but the file still has to exist in RAM once
None of these are server-shaped problems pretending to be local. They are the honest boundaries of the approach, and knowing them is the difference between a local tool and a local toy.
This is post #3 in the local-first series: #1 why we built 166 no-upload tools, #2 browser-side AI segmentation. All 166 tools live at ToolVault.
Top comments (0)