
You have three PDFs: a contract, an appendix, and a signature page. You need one file.
The obvious solution is an online PDF merger. But here's the catch — most of them upload your files to a server first. That contract with confidential terms? Now it's on someone else's cloud.
In this post, I'll show you how to merge PDFs entirely in the browser using JavaScript, with a stack I've been running in production for months.
Why Browser-Side Merging Matters
There are two architectures for online PDF tools:
| Architecture | How it works | Privacy |
|---|---|---|
| Server-side | Upload → server processes → download | ❌ Your file leaves your device |
| Browser-side | File loads in browser tab → JavaScript processes → download | ✅ File never leaves your device |
For personal photos, server-side is fine. For contracts, NDAs, medical records, or financial statements — browser-side is the only option I'd trust.
The trade-off: Browser memory limits. A 500MB scanned document might crash a tab. For typical documents under 100MB, browser-side works smoothly.
The Stack: pdf-lib
The core library is pdf-lib, a powerful PDF manipulation library that runs in Node.js and browsers with zero polyfills.
npm install pdf-lib
What it can do:
- Create, modify, and merge PDFs
- Add watermarks, annotations, and form fields
- Set passwords and permissions
- Extract and embed fonts
For merging specifically, the API is surprisingly clean.
Basic Merge: Two Lines of Code
Here's the simplest possible merge:
import { PDFDocument } from 'pdf-lib';
async function mergePDFs(files) {
// Create a new empty PDF
const mergedPdf = await PDFDocument.create();
for (const file of files) {
// Load each uploaded file
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
// Copy all pages into the merged document
const copiedPages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
copiedPages.forEach(page => mergedPdf.addPage(page));
}
// Save and return
const pdfBytes = await mergedPdf.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
What's happening:
- Create a blank PDF document
- Loop through each uploaded file
- Load it, copy all pages, paste them into the merged doc
- Save the result as a downloadable Blob
That's it. No server, no upload, no privacy trade-off.
Adding Drag-and-Drop Reordering
The basic merge appends files in the order they're selected. In real apps, users want to reorder.
Here's how I handle it in sotool:
// State: array of { id, file, name }
const fileList = reactive([]);
// User drags file from index oldIndex to newIndex
function reorderFiles(oldIndex, newIndex) {
const item = fileList.splice(oldIndex, 1)[0];
fileList.splice(newIndex, 0, item);
}
// Merge respects the current order
async function mergeInOrder() {
const mergedPdf = await PDFDocument.create();
for (const item of fileList) {
const pdf = await PDFDocument.load(await item.file.arrayBuffer());
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach(page => mergedPdf.addPage(page));
}
return mergedPdf.save();
}
The key insight: fileList order determines merge order. The UI layer (Vue/React/vanilla JS) handles drag-and-drop. The merge logic just iterates the array.
Granular Control: Merging Specific Page Ranges
Sometimes you don't want the whole file. Maybe you only need pages 1-5 from a 20-page document.
async function mergeWithRanges(files, ranges) {
// ranges = [{ fileIndex: 0, pages: [0, 1, 2] }, ...]
const mergedPdf = await PDFDocument.create();
for (const range of ranges) {
const file = files[range.fileIndex];
const pdf = await PDFDocument.load(await file.arrayBuffer());
// copyPages accepts specific page indices
const copiedPages = await mergedPdf.copyPages(pdf, range.pages);
copiedPages.forEach(page => mergedPdf.addPage(page));
}
return mergedPdf.save();
}
Use case: A user uploads a 10-page contract and only wants pages 1, 3, and 7-9.
Performance: Handling Large Files
Browser tabs have memory limits (~1-2GB in Chrome). When users drop massive scanned documents, the tab can freeze.
Two optimizations I use:
1. Incremental Loading (when possible)
For simple merges, pdf-lib loads the entire file into memory. If you only need specific pages, there's no way around it — you need the file structure. But for very large files, warn the user:
const MAX_SIZE_MB = 100;
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
alert('Files over 100MB may be slow. Consider using desktop software.');
}
2. UI Responsiveness
Merging blocks the main thread. For multiple files, chunk the work:
async function mergeWithProgress(files, onProgress) {
const mergedPdf = await PDFDocument.create();
for (let i = 0; i < files.length; i++) {
const pdf = await PDFDocument.load(await files[i].arrayBuffer());
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach(page => mergedPdf.addPage(page));
// Yield to UI thread
onProgress(i + 1, files.length);
await new Promise(r => setTimeout(r, 0));
}
return mergedPdf.save();
}
The setTimeout(..., 0) lets the browser update progress bars and handle user input between files.
The Download Step
Once you have the merged PDF bytes, trigger a download:
function downloadPDF(pdfBytes, filename = 'merged.pdf') {
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
Pro tip: Use the original filename as a base:
const originalName = files[0].name.replace(/\.pdf$/i, '');
downloadPDF(pdfBytes, `${originalName}-merged.pdf`);
Limitations (Honest Assessment)
Browser-side merging isn't perfect:
| Limitation | Why | Workaround |
|---|---|---|
| Memory caps | Browser tabs limited to ~1-2GB | Warn users, process in batches |
| Bookmarks lost | Merging restructures document tree | Acceptable for most use cases |
| Internal links broken | Page references change after merge | Rarely an issue for merged contracts |
| No OCR | Would require Tesseract.js | Use desktop software for scanned PDFs |
For 95% of daily tasks — merging a few contracts, combining report sections, assembling portfolios — these limitations don't matter.
Try the Live Tool
If you want to test browser-based PDF merging without writing code:
Features:
- Drag-and-drop file upload
- Visual thumbnail reordering
- Page-level selection (merge only specific pages)
- Pure browser-side processing
- Free, no signup
The source code is open source if you want to see how the Vue 3 + pdf-lib integration works in practice.
What's Next?
If you're building browser-based document tools, the next challenges are:
- Splitting PDFs by page ranges (similar API, reverse operation)
- Compressing PDFs by downsampling embedded images
- Watermarking with text or image overlays
All three are doable with pdf-lib in the browser. I'll cover splitting and compression in future posts.
Have you built browser-based PDF tools? How do you handle large files or memory constraints? Let me know in the comments.
Top comments (0)