Doing PDF work entirely in the browser is a nicer story than it sounds. pdf-lib loads a document from an ArrayBuffer, gives you pages as objects, and hands back bytes — no server, no upload, no retention policy anyone has to trust. The happy path is about fifteen lines.
The unhappy paths are where the actual engineering is, and none of them announce themselves. Here are the four that cost me the most.
Encrypted PDFs can't be modified, and most tools lie about it
This is the one that generates support emails.
A PDF can carry two different passwords. A user password stops you opening the file at all — obvious, and users understand it. An owner password lets anyone open and read the document while restricting what can be done to it. Files that come out of banks, insurers and government portals very often have one, and nothing in the viewer tells you.
pdf-lib will refuse to load such a document:
import { PDFDocument } from 'pdf-lib';
try {
const pdf = await PDFDocument.load(bytes);
} catch (err) {
// "Input document to `PDFDocument.load` is encrypted."
}
There is an ignoreEncryption: true option, and it is a trap. It gets you past the exception, and the object you get back is structurally incomplete. Merge it with another document and you produce a file that opens to blank pages, or doesn't open at all. The operation "succeeded" and the output is garbage.
Plenty of online PDF tools take exactly that shortcut. You upload a protected file, the spinner runs, you get a download, and the result is broken — with no explanation, because from the tool's point of view nothing failed.
The honest handling is to detect the condition and say so:
let pdf;
try {
pdf = await PDFDocument.load(bytes);
} catch (err) {
if (String(err).includes('encrypted')) {
throw new UserFacingError(
'This PDF is password-protected and cannot be modified. ' +
'Remove the protection in the application that produced it, then try again.'
);
}
throw err;
}
It's a worse-looking outcome and a much better one. "I can't do this, here's why" beats a corrupted file every time.
The memory ceiling is real, and it kills the tab silently
Everything lives in the tab. The original ArrayBuffer, the parsed object graph, the serialised output — at peak you are holding several copies of the document at once. A 300-page scan at 300 dpi is a few hundred megabytes before you've done anything useful, and on mobile the tab just dies. No exception you can catch, no onerror, nothing to report. The page is simply gone.
So the size guard has to come before parsing, not after:
const MAX_BYTES = 100 * 1024 * 1024;
if (file.size > MAX_BYTES) {
throw new UserFacingError(
`This file is ${(file.size / 1048576).toFixed(0)} MB. ` +
`Browser memory tops out well below that — PDFsam or Stirling PDF on your desktop ` +
`will handle it comfortably.`
);
}
Naming a tool that does the job better is not a defeat. Someone with a 400 MB scan is not going to be served by any browser, and pretending otherwise wastes their afternoon.
Two things that help below the ceiling: process sequentially rather than loading every input at once, and drop references as you go so the collector can actually reclaim them. Merging ten files one at a time uses a fraction of the peak of merging them in parallel.
"Compress this PDF" means two completely different jobs
Users ask for one thing and mean one of two, and the difference decides whether you can help at all.
If the PDF came from a scanner, there is no text inside it — there's a photograph per page. Ninety-five percent of the weight is images, and compression works spectacularly: re-encoding those images or dropping them from 600 dpi to 200 can take a file down by an order of magnitude, and it stays perfectly readable.
If the PDF was generated from a word processor, the text weighs almost nothing. The size is in embedded fonts and images, and there's very little to win. A tool promising 80% off that kind of file is rasterising it behind your back — turning every page into a picture. The file gets smaller, and the text stops being selectable, searchable and accessible. Most people don't notice until they try to search the document weeks later.
Telling the two apart is cheap. Extract the text; if there's essentially none across the pages, you're looking at a scan:
const looksLikeScan = (extractedChars, pageCount) =>
extractedChars / pageCount < 50;
Say which one you're dealing with, and what the realistic outcome is. Managing that expectation up front is worth more than any codec.
pdf-lib doesn't render, and that surprises people
pdf-lib manipulates document structure — pages, metadata, annotations, form fields. It does not draw anything. If you want thumbnails so users can pick pages before splitting, you need pdf.js, which is a separate library with a separate footprint.
Shipping both is the normal answer, and it roughly doubles what the user downloads before anything happens. Worth loading the renderer lazily, only when a preview is actually requested.
Why do it client-side at all
The honest summary is that it wins on trust and loses on scale.
Every "we delete your files after an hour" promise is a statement about what a company does after receiving your document. It might be entirely true. It's still a promise, and for a signed contract or a medical record that's a decision worth making deliberately rather than by default.
When the work happens in the browser, there's nothing to promise. The request doesn't exist, and anyone can confirm it in ten seconds: open DevTools, Network tab, run the operation, watch nothing leave. That test works on any site, including mine — I run pdfonlinefree.com, which is where all of the above came from.
What you give up is everything that needs real memory or real CPU. Hundred-page scans, batch jobs, OCR. For those, a desktop application is simply the right tool and I'd say so to anyone who asked.
The failure modes are the transferable part. Every one of them is silent by default, and every one of them is a place where the easy implementation reports success while handing the user something broken.
Top comments (0)