Almost every "free" PDF tool lets you merge, split, rotate and reorder pages for free — and then puts a paywall on the one button you actually came for: Export. I got annoyed enough to build a PDF toolkit that doesn't, so here's the honest technical reason it happens and what it takes to avoid it.
Reading a PDF is cheap. Writing a valid one back is not.
Rendering a PDF to show it on screen is a solved, cheap problem — the browser and a dozen libraries do it. The expensive part is going the other way: taking your edits and serializing a spec-valid PDF back out. The PDF format is a cross-referenced object graph with an xref table, object streams, and byte-offset bookkeeping that has to stay internally consistent or the file won't open.
That asymmetry is why so many tools happily show you a preview and let you rearrange thumbnails, then charge at export: the preview is free, the write-out is the work.
The second reason: they uploaded your file to do it
The other common pattern is a server round-trip. Your document goes up to their backend, a headless tool (Ghostscript, a licensed SDK, LibreOffice) does the manipulation, and the result comes back. That costs them real money per file — CPU, bandwidth, storage — which is exactly why it eventually needs to cost you money, and why the free tier is capped at "3 files a day."
For a contract, an ID scan, or a payslip, silently shipping the document to someone's server is also a privacy problem people don't think about until later.
Doing it client-side with pdf-lib
The fix for both problems is the same: never upload, and do the write-out in the browser. pdf-lib is the key piece — it can create and modify existing PDFs entirely in JS, including the parts that are annoying to get right:
import { PDFDocument } from 'pdf-lib';
// merge: copy pages between documents, re-embedding fonts/resources
const out = await PDFDocument.create();
for (const bytes of files) {
const src = await PDFDocument.load(bytes);
const pages = await out.copyPages(src, src.getPageIndices());
pages.forEach((p) => out.addPage(p));
}
const merged = await out.save(); // valid xref + object streams, in the browser
copyPages is the bit that matters: it deep-copies the page's resource dictionary (fonts, images, shared objects) into the target so the merged file is actually valid, not just concatenated bytes. The same document model handles split, extract/remove/reorder, rotate, N-up, page numbers and watermarks — all as object-graph edits, then one save().
A few things that bite:
- Compression / grayscale aren't first-class in pdf-lib; you work at the content-stream and image-XObject level, which is fiddly but keeps it local.
- OCR (making a scanned PDF searchable) needs a separate WASM engine (Tesseract) — heavy, so lazy-load it only when the user actually asks.
- Editing existing body text is the one thing you genuinely can't fake: PDF has no reflowable text model, so "change the words" still means a real editor like LibreOffice Draw. Be honest about that limit instead of pretending.
Why bother
Client-side means the file never leaves the device, there's no per-file server cost, so the export can just be free — permanently — and static hosting keeps it that way. That's the whole reason pdfonlinefree.com exists: merge, split, compress, sign, OCR, unlock, page numbers, watermark, JPG↔PDF — export included, nothing uploaded.
If you're building something similar: put the expensive write-out in the browser, lazy-load the WASM parts, and don't paywall the button people came for.
Top comments (0)