ondelva

← Blog

PDF Tools That Never Upload: Traps Between pdf-lib and pdf.js

Four PDF tools on this site run entirely in the browser. Getting there meant two libraries that each do half the job, an ArrayBuffer that vanishes when you share it, canvases that fail without an error, and a test that changed the file it was checking.

Yunseon Hong7 min read

This site has four PDF tools: merge, PDF to image, rearrange, rotate and delete pages, and image to PDF. None of them upload anything. The file is read, changed, and handed back inside the tab. That is the whole reason they exist, and it is also why most of what follows was hard. A server-side converter can throw memory at a 900 MB merge. A tab has a fixed heap, one main thread, and a browser that pauses it whenever the user looks away.

Everything here was measured with pdf-lib 1.17.1 and pdfjs-dist 6.2.108, in headless Chromium on an Apple Silicon Mac, against production builds, in late August 2026.

Two libraries, and neither does both halves

pdf-lib reads and writes. It can move pages, copy them into a new document, and save the result. It cannot draw a page as pixels. pdf.js draws. It cannot write a file. A tool that shows thumbnails and saves a result needs both, and between them they are heavy. pdf-lib is 171 KB gzipped, pdf.js is 123 KB, and the pdf.js worker is another 364 KB.

So nothing heavy loads with the page. The libraries arrive through dynamic imports when the first file is dropped, and the worker arrives when the first thumbnail is drawn. A tool that only reads, like PDF to image, never imports pdf-lib at all, because pdf.js already reports page count, page size, and whether the file is encrypted. Image to PDF is the mirror case. The browser’s own image decoder gives it dimensions and a preview, so it never loads pdf.js.

Then the two libraries started fighting over the same bytes.

pdf.js takes your ArrayBuffer with it

When you hand pdf.js a file, it transfers the underlying ArrayBuffer to its worker. A transferred buffer is detached on the main thread, which means it now has length zero. If a pdf-lib document was built from the same buffer, that document breaks on the spot. The merge tool loads every file in pdf-lib and also draws its first page with pdf.js, so on that tool the two always share bytes.

The fix is one call, bytes.slice(0), which gives pdf.js its own copy. The test that guards it is the part worth keeping: draw a thumbnail first, then merge, then check that the merged output is correct. A test that only merges never sees the problem.

The encryption error you cannot catch by type

pdf-lib throws EncryptedPDFError for a password-protected file, and err instanceof EncryptedPDFError is false. The class was compiled down to ES5, so the prototype chain is broken and err.name is just "Error". The only thing left that identifies it is the English message, and branching on a message is a promise to break the day the wording changes.

What works is opening the file with { ignoreEncryption: true } and reading the public doc.isEncrypted property. Ignoring encryption to inspect a file is fine. Ignoring it to write one is not: the streams are still ciphertext, so you get a document with blank pages and no error. pdf.js is better behaved here. Its PasswordException sets name properly, even after crossing the worker boundary.

The two libraries also disagree on one case. A file with only an owner password, the kind that restricts printing or editing but opens without a prompt, is rejected by pdf-lib’s isEncrypted and opened normally by pdf.js. For a tool that draws pages, opening it is the right call.

Canvases fail silently past a size nobody documents

Browsers cap canvas dimensions and total area, and the caps are not in any spec. Exceed them and you get no exception, just a blank image. I clamp every render to the lowest limits I know of, from iOS Safari: 8,192 pixels on a side and 16,777,216 pixels in total. The tool scales down to fit while keeping the aspect ratio, and says once on screen that it did.

In practice this is rare. An A4 page at 300 DPI is about 8.7 megapixels and never gets close. A poster page at high DPI does. So does a large AVIF in the image-to-PDF tool, which has to be redrawn through a canvas because pdf-lib only embeds JPEG and PNG.

Hidden tabs throttle the work

Converting 296 pages is a loop, and a long loop on the main thread freezes the page. The usual fix is to yield between pages so the UI can paint. requestAnimationFrame looks like the natural way to do that, and it is the wrong one. It does not fire in a hidden tab, and Chrome’s own timer throttling notes say it waits for the page to be visible. Start a conversion, switch tabs while you wait, and the conversion does not start.

setTimeout(fn, 0) does fire in a hidden tab, but slowly. Chrome checks timers in hidden pages once per second, and after five minutes hidden, for chains of five or more, once per minute. Yield after every page and a 296-page job takes at least 296 seconds in the background. The loop now yields only while document.visibilityState === "visible" and runs flat out otherwise, since nobody is watching it.

Two files are already too many downloads

Saving each page as its own download does not work past the first. Chrome asks permission for the second download from an origin, and if the user denies it or misses the prompt, the remaining files go nowhere. Two or more images now come back as one ZIP.

I wrote the ZIP by hand. PNG data is already compressed with deflate, so storing it uncompressed in the archive costs almost nothing, and an uncompressed ZIP is just headers plus a CRC-32 for each file. That kept a dependency out. It also let the archive be a Blob built from an array of chunks, which keeps those bytes off the JavaScript heap.

The CRC turned out to be the slow part. Over 100 MB, a for...of loop over the bytes took 979 ms and a plain indexed for loop took 273 ms, about 3.6 times faster. That time is a frozen main thread, so the indexed loop stayed.

Inspecting the output changed the output

This one nearly produced a wrong claim on the tool pages. I wanted to confirm that merged files carry no new metadata: no producer string, no creation date. So I opened the saved file with pdf-lib and read the fields. They were full of fresh timestamps.

They were written by my check. PDFDocument.load() defaults to updateMetadata: true, so opening a file sets Producer, Creator, CreationDate, and ModDate on the in-memory copy before you read anything. Opened with { updateMetadata: false }, the output was clean, both of new values and of the original file’s title and author. Searching the raw bytes with a regex does not work either, because the info dictionary can sit inside a compressed object stream, which makes even the original’s producer invisible. A check has to read the file without changing it, and the default here does not.

Two readings of one bad rotation

PDF pages carry a /Rotate value that should be a multiple of 90. When it is not, the libraries disagree. pdf.js treats anything else as 0 and draws the page upright. pdf-lib reports the value as written, and its setRotation only accepts multiples of 90. A page tagged /Rotate 45 shows at 90 degrees after the user rotates it once in the preview, and saves at 135. The page editor now normalizes rotation with the same rule pdf.js uses, so the preview and the saved file always agree.

Memory runs out before patience does

The merge numbers, with synthetic scans where every page is a different JPEG:

InputReadMergeOutputJS heap
4 files, 240 pages126 ms270 ms76 MB242 MB
16 files, 960 pages330 ms903 ms304 MB954 MB
48 files, 2,880 pages882 ms2.9 s912 MB2,823 MB of 3,586 MB

Under three seconds to merge nearly 3,000 pages is fine. Using 79% of the heap limit is not, and moving the work into a Web Worker would not help, because a worker gets the same heap budget. It would stop the page from freezing and change nothing about the ceiling, so I did not build one.

PDF to image behaves differently because it never holds the whole input. It reuses one canvas, encodes each page to PNG right away, and lets the canvas go. 296 pages at 150 DPI took 5.6 seconds, produced a 96 MB ZIP, and peaked at 104 MB of heap. Heap roughly equal to output size is the sign that the structure is right.

Image to PDF is the extreme case. Thirty 3000×2000 JPEGs, 92 MB in total, became a 92 MB PDF in 0.3 seconds, because JPEG and PNG bytes go into the file as they are, with no decoding and no re-encoding. The list of photos waiting to be converted held 6 MB of heap, because it keeps Blob references and reads each file only when it is written.

If I build a fifth tool, I will start from the memory table rather than from features. Speed was never the complaint. A tab running out of heap halfway through someone’s 900 MB merge would be.

Filed under Build log