How Merging PDFs Works in the Browser, and What Gets Lost
A visa application wants your bank statements, payslips and passport scan as one PDF. You have six files. Most online mergers want you to upload all six to a server you know nothing about, which is a strange thing to do with a bank statement. I built Merge PDF so the whole job happens on your own device, and on the way I learned exactly what a merge keeps and what it quietly drops.
Two libraries, two jobs
The tool uses two libraries, because no single one does both jobs well:
- pdf-lib reads and writes the PDF structure. It does the actual merge, and it’s the one that can tell if a file is encrypted.
- pdf.js (Mozilla’s PDF renderer) draws pages. It’s only used for the previews, so you can check you picked the right files.
Neither loads with the page. pdf-lib is imported the first time you pick a PDF, and pdf.js loads right after it, in the background, with one worker shared by every open file (by default pdf.js would start one per document).
There’s a small catch in sharing the same bytes. pdf.js moves its data into the worker, which leaves the original buffer unusable on the main thread. So openPreview reads the file again and gives pdf.js its own copy, and pdf-lib keeps the one it parsed. Each file sits in memory twice, which is part of why there’s a size limit.
Checking a file before trusting it
Every file goes through the same checks, in this order:
- Size. Up to 100 MB per PDF. Bigger files get a message suggesting your PDF app’s “Reduce file size” option.
- Header. The bytes must contain
%PDF-. The check looks anywhere in the first 1,024 bytes, not only at byte 0, because some generators put junk before the header. A file renamed to.pdffails here, with a message that says so. - Parsing and encryption. This is the interesting one:
const { PDFDocument } = await import('pdf-lib') // only loaded once a PDF is picked
try {
// ignoreEncryption so encrypted files load and can be reported, instead of throwing a generic error
const doc = await PDFDocument.load(bytes, { ignoreEncryption: true, updateMetadata: false })
if (doc.isEncrypted) return { issue: pdfIssue(file.name, 'encrypted') }
const pageCount = doc.getPageCount()
if (!pageCount) return { issue: pdfIssue(file.name, 'empty') }
return { doc, bytes, pageCount }
}
catch (e) {
return { issue: pdfIssue(file.name, e instanceof RangeError ? 'memory' : 'damaged') }
}
pdf-lib can’t decrypt PDFs. Without ignoreEncryption, an encrypted file throws an error that doesn’t say much. With it, the file loads, isEncrypted is true, and the tool can give a specific message. Copying pages out of an encrypted file would produce blank or garbled pages, so it’s refused.
That includes PDFs you can open without a password. A file with only editing restrictions (no printing, no copying) is still encrypted inside, so it’s refused too. The message suggests the usual fix: print it to a new PDF from your PDF app, or save a copy without protection, and add that copy.
A RangeError while parsing usually means the browser ran out of memory, so it gets the “close other tabs” message instead of “damaged”.
The merge itself
Once the files are loaded, the merge is short:
const { PDFDocument } = await import('pdf-lib')
const out = await PDFDocument.create()
out.setProducer('ToolOnTap')
out.setCreator('ToolOnTap – toolontap.com')
for (const [i, doc] of docs.entries()) {
const pages = await out.copyPages(doc, doc.getPageIndices())
for (const page of pages) out.addPage(page)
onProgress?.(i + 1)
}
copyPages does the heavy lifting. A PDF page is an object that points to other objects: its content stream (the drawing instructions), the fonts and images it uses, and its annotations. copyPages follows those links and copies everything the page needs into the new document. Nothing is drawn or converted, so text stays text you can select and search, and scanned images keep their original compression. Each page keeps its own size and orientation, so an A4 letter and a US Letter form can sit next to each other unchanged.
The progress callback runs after each file, which is where the button’s “Merging file 2 of 6…” comes from. The result is the files in the order of the list, from top to bottom. You drag them, or use the arrow buttons, to change that order.
What doesn’t come along
The merge copies pages. Anything that belongs to the document as a whole, and not to a page, stays behind. The code says it in one comment: “document-level parts (bookmarks, form data) don’t”.
| Part of the PDF | Where it lives | After the merge |
|---|---|---|
| Text, images, vector drawings | The page | Kept exactly |
| Web links on the page | The page’s annotations | Kept |
| Page size and rotation | The page | Kept |
| Bookmarks (the sidebar outline) | The document catalog | Dropped |
| Form fields | Widgets on the page, field tree in the catalog | May stop being editable |
| Title, author, keywords | Document info | Not copied; Producer is set to ToolOnTap |
Form fields are the subtle case. What you see of a field (the box and any value drawn in it) is a widget annotation on the page, and it comes along. But the list that tells a PDF reader “these widgets are form fields” sits in the document catalog, and that doesn’t. So a merged form can look right and still not let you type in it. That’s why the tool’s tip says to fill in and save forms before merging. Your answers then travel as part of the page.
Previews without rendering every page
Each file in the list shows its first page, 44 pixels wide, and you can open any file to look through all its pages. A 300-page statement shouldn’t mean rendering 300 pages up front, so the preview component waits:
- It uses an
IntersectionObserverand renders a page only when it comes within 300 pixels of the screen. - It renders at twice the shown width, so previews stay sharp on high-density screens.
- It turns the finished canvas into a small JPEG (quality 0.8) and shows that as an image. A JPEG blob takes a fraction of the memory a live canvas would, which matters on long documents.
- It keeps every canvas under the browser’s size limit. iOS refuses a canvas that’s too big and simply draws nothing.
If a preview fails, the file still merges. The preview is there to help you check the order, and it never blocks the job.
Memory and the 100 MB limit
There’s no limit on the number of files. The real limit is your device’s memory: each file is held by pdf-lib and pdf.js, and the merged result is built in memory too before you download it. That’s why each file is capped at 100 MB, and why very large merges work best on a computer rather than a phone.
The merged PDF is about as big as all the files together, since nothing is re-compressed. If you’re sending it by email, keep in mind that email services usually cap attachments at 20 to 25 MB.
Try it
Merge PDF joins two or more PDFs in the order you choose, with a page count and a preview for each. If you need only part of a file first, Split PDF can extract the pages you need, and Rearrange PDF pages changes their order. The rest are on the PDF tools page.