Most web applications that manipulate PDF files follow an outdated pattern:
- The user uploads confidential tax forms, bank statements, or medical records to a remote server.
- An expensive backend microservice (running Python with
PyPDF2, or headless Chrome) processes the file. - The server stores the temporary output, incurs disk I/O and egress bandwidth costs, and returns a download link.
In 2026, sending private PDFs across the network just to merge or split pages is an anti-pattern. Modern client devices (laptops, phones, and tablets) have more than enough compute power and memory to manipulate PDFs locally in memory.
In this guide, we'll build a 100% client-side PDF merger using JavaScript and pdf-lib. No files ever touch a server, meaning zero bandwidth costs for your backend, zero storage liabilities, and instant GDPR/HIPAA compliance by design.
Why Client-Side PDF Processing Wins
Before diving into code, consider the architectural benefits of processing PDFs directly in the browser:
- Absolute User Privacy: Confidential documents (passwords, contracts, IDs) never traverse external networks or sit in transient cloud buckets.
- $0 Infrastructure & Egress Bills: Your server only delivers static JavaScript and HTML bundles (served over a CDN). You never pay for CPU rendering or file storage.
- Offline / Edge Capable: Once the bundle is cached via a Service Worker, users can merge PDFs even on airplanes or in spotty subway Wi-Fi.
(We recently integrated this zero-server architecture into UtilifyAI's Free PDF Suite to handle unlimited document combinations without requiring user sign-ups or server uploads).
Step 1: Installing / Importing pdf-lib
pdf-lib is a lightweight, pure TypeScript/JavaScript library for creating and modifying PDF documents in any modern browser without native binary dependencies.
If you are using npm/Next.js/Vite:
npm install pdf-lib
If you are using vanilla HTML/JS, load it from a CDN:
<script src="https://unpkg.com/pdf-lib@1.17.9/dist/pdf-lib.min.js"></script>
Step 2: The Core PDF Merging Function
Here is the complete asynchronous function that takes an array of browser File objects (e.g. from an <input type="file" multiple> or drag-and-drop zone) and merges them into a single downloadable PDF blob.
import { PDFDocument } from 'pdf-lib';
/**
* Merges multiple PDF files entirely within the client's browser memory.
* @param {File[]} pdfFiles - Array of File objects selected by the user.
* @param {Function} onProgress - Optional callback reporting progress (0 to 100).
* @returns {Promise<Blob>} Merged PDF as an application/pdf Blob.
*/
export async function mergePDFsInBrowser(pdfFiles, onProgress) {
if (!pdfFiles || pdfFiles.length < 2) {
throw new Error('Please select at least two PDF documents to merge.');
}
// 1. Create a fresh target PDF document
const mergedPdf = await PDFDocument.create();
const totalFiles = pdfFiles.length;
for (let i = 0; i < totalFiles; i++) {
const file = pdfFiles[i];
// Read the File as an ArrayBuffer directly from browser memory
const fileBuffer = await file.arrayBuffer();
// Load the source PDF into pdf-lib (ignore encryption warnings if permitted)
const sourcePdf = await PDFDocument.load(fileBuffer, {
ignoreEncryption: true
});
// Copy all pages from the source document to the target
const pageIndices = sourcePdf.getPageIndices();
const copiedPages = await mergedPdf.copyPages(sourcePdf, pageIndices);
for (const page of copiedPages) {
mergedPdf.addPage(page);
}
// Report progress if callback is provided
if (onProgress) {
onProgress(Math.round(((i + 1) / totalFiles) * 100));
}
}
// 2. Serialize the merged PDF into a Uint8Array
const mergedPdfBytes = await mergedPdf.save();
// 3. Return as a native browser Blob
return new Blob([mergedPdfBytes], { type: 'application/pdf' });
}
Step 3: Triggering a Download Without Server Round-Trips
Once you have the merged Blob, you can trigger an instant browser download using standard DOM URL APIs:
/**
* Triggers a native browser file download for a generated Blob.
* @param {Blob} blob - The merged PDF Blob.
* @param {string} filename - Desired output filename.
*/
export function downloadBlob(blob, filename = 'merged-document.pdf') {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
// Cleanup object URL from memory after download triggers
setTimeout(() => {
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, 100);
}
Step 4: Putting It Together with Interactive UI
Here is a minimal React/Next.js component with progress indication and drag-and-drop support:
import React, { useState } from 'react';
import { mergePDFsInBrowser, downloadBlob } from './pdfMerger';
export default function ClientPdfMerger() {
const [files, setFiles] = useState([]);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const handleFileChange = (e) => {
setFiles(Array.from(e.target.files));
};
const handleMerge = async () => {
if (files.length < 2) return;
setIsProcessing(true);
setProgress(0);
try {
const mergedBlob = await mergePDFsInBrowser(files, (p) => setProgress(p));
downloadBlob(mergedBlob, 'merged_utilifyai.pdf');
} catch (err) {
console.error('Merge failed:', err);
alert('Failed to merge documents: ' + err.message);
} finally {
setIsProcessing(false);
}
};
return (
<div className="p-6 border border-slate-700 rounded-xl bg-slate-900 text-white max-w-lg mx-auto">
<h2 className="text-xl font-bold mb-4">Merge PDFs In Your Browser</h2>
<input
type="file"
multiple
accept="application/pdf"
onChange={handleFileChange}
className="mb-4 block w-full text-sm text-slate-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:bg-blue-600 file:text-white"
/>
{files.length > 0 && (
<p className="text-sm text-slate-300 mb-4">{files.length} documents selected.</p>
)}
<button
onClick={handleMerge}
disabled={isProcessing || files.length < 2}
className="w-full py-2.5 px-4 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 font-semibold rounded-lg transition"
>
{isProcessing ? `Merging (${progress}%)...` : 'Merge & Download'}
</button>
</div>
);
}
Memory Optimization & Edge Cases
When working in browser memory, keep these two tips in mind:
- Handling 100MB+ Documents: If users merge massive scans, JavaScript heaps can exceed memory caps on low-end mobile devices. To avoid memory crashes, batch page copies and release ArrayBuffers as soon as each document is copied.
-
Password Protected / Restricted PDFs: If a source PDF has permissions restrictions, standard loaders may throw errors. Setting
ignoreEncryption: trueinpdf-liballows merging unrestricted pages, while encrypted user passwords must be supplied or unlocked beforehand using a client-side PDF unlock utility.
Summary
By taking advantage of modern JavaScript and WebAssembly capabilities, you eliminate backend infrastructure, provide instantaneous response times, and offer enterprise-grade privacy to your users.
If you'd like to test this in action without writing boilerplate code, test the production implementation over at UtilifyAI PDF Merge Tool.
Top comments (0)