How to Merge PDF Files in the Browser Without Server Uploads (Zero-Server Architecture)
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 Py
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/[email protected]/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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.