Learn how to merge multiple PDF files or extract specific pages securely. Discover why client-side PDF manipulation is the only safe way to handle sensitive documents.
Process sensitive PDFs without uploading a single byte. This guide explains the security model behind client-side (in‑browser) PDF tools, then shows production-ready ways to merge and split PDFs locally using modern JavaScript and pdf-lib. You’ll also find performance tips, compliance notes, accessibility guidance, and answers to common edge cases like scanned or rotated PDFs.
In one sentence: Client-side PDF processing runs entirely on your device, so you can merge and split PDFs privately, fast, and with zero server risk.
When you upload a PDF to a typical online service, the provider copies your file to a remote server to process it. Even reputable vendors can introduce risk:
Bottom line: If your PDFs include personal, health, financial, legal, or proprietary information, server-based tools create unnecessary exposure.
Client-side processing flips the model: the app code is downloaded once; all PDF manipulation happens locally inside your browser’s process. Your files never leave your device.
The result: better privacy, dependable performance, and no vendor throttling, queues, or surprise upload limits.
Use our in-browser Merge and Split tools to handle documents securely and instantly:
Great for:
Tips:
1, 3, 7 for discrete pages10-15 for a consecutive range2-4, 9, 12-16 for a mixTips:
20-25 yields pages 20, 21, 22, 23, 24, 25.Below are fully working examples showing how to build local-only merge and split features using pdf-lib. These snippets run in modern browsers with no server required.
Important: Use single quotes in code to avoid JSON escaping issues when copying this article into automation.
Use a bundler or import from a CDN.
npm install pdf-lib
Basic ESM import:
import { PDFDocument, degrees } from 'pdf-lib';
Or load from a CDN using a type='module' script. See the pdf-lib docs for details.
import { PDFDocument } from 'pdf-lib';
// Utility: trigger a download of a Blob
function downloadBlob(blob, filename = 'merged.pdf') {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
// Revoke on next tick to avoid Firefox race conditions
setTimeout(() => URL.revokeObjectURL(url), 0);
}
// Merge an array of File objects (from an <input> or drag & drop)
export async function mergePdfs(files, { outputName = 'merged.pdf' } = {}) {
if (!files || !files.length) throw new Error('No files provided');
const merged = await PDFDocument.create();
// Process sequentially to keep memory bounded
for (const file of files) {
const bytes = await file.arrayBuffer();
let src;
try {
src = await PDFDocument.load(bytes);
} catch (err) {
// Common cause: encrypted/password-protected PDF (not supported by pdf-lib)
console.warn(`Skipping file '${file.name}':`, err);
continue;
}
const pageCount = src.getPageCount();
const indices = Array.from({ length: pageCount }, (_, i) => i);
const copied = await merged.copyPages(src, indices);
copied.forEach(p => merged.addPage(p));
// Yield to the event loop to keep the UI responsive for huge inputs
await new Promise(requestAnimationFrame);
}
// Optionally sanitize basic metadata (helps reduce accidental leakage)
// merged.setTitle('');
// merged.setAuthor('');
// merged.setSubject('');
// merged.setKeywords([]);
// merged.setCreator('Local PDF Merge');
// merged.setProducer('Local PDF Merge');
const mergedBytes = await merged.save();
const blob = new Blob([mergedBytes], { type: 'application/pdf' });
downloadBlob(blob, outputName);
return blob; // return for further programmatic use if needed
}
Usage example:
const input = document.querySelector('#merge-input');
input.addEventListener('change', async (e) => {
const files = Array.from(e.target.files || []);
await mergePdfs(files, { outputName: 'combined.pdf' });
});
import { PDFDocument } from 'pdf-lib';
function parsePageRanges(input, pageCount) {
// Accepts strings like '1,3,5-7' (1-based). Returns sorted unique 0-based indices.
if (!input || typeof input !== 'string') return [];
const cleaned = input.replace(/\s+/g, '');
if (!cleaned) return [];
const indices = new Set();
for (const token of cleaned.split(',')) {
if (!token) continue;
if (token.includes('-')) {
const [a, b] = token.split('-');
const start = Math.max(1, parseInt(a, 10));
const end = Math.min(pageCount, parseInt(b, 10));
if (Number.isFinite(start) && Number.isFinite(end) && start <= end) {
for (let n = start; n <= end; n++) indices.add(n - 1);
}
} else {
const n = parseInt(token, 10);
if (Number.isFinite(n) && n >= 1 && n <= pageCount) indices.add(n - 1);
}
}
return Array.from(indices).sort((x, y) => x - y);
}
function downloadBlob(blob, filename = 'split.pdf') {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export async function splitPdf(file, rangeText, { outputName = 'split.pdf' } = {}) {
if (!file) throw new Error('No file provided');
const bytes = await file.arrayBuffer();
const src = await PDFDocument.load(bytes); // Will throw on encrypted PDFs
const pageCount = src.getPageCount();
const wanted = parsePageRanges(rangeText, pageCount);
if (!wanted.length) throw new Error('No valid pages specified');
const out = await PDFDocument.create();
const copied = await out.copyPages(src, wanted);
copied.forEach(p => out.addPage(p));
const outBytes = await out.save();
const blob = new Blob([outBytes], { type: 'application/pdf' });
downloadBlob(blob, outputName);
return blob;
}
Usage example:
const input = document.querySelector('#split-input');
const field = document.querySelector('#ranges');
document.querySelector('#do-split').addEventListener('click', async () => {
const file = input.files && input.files[0];
const ranges = field.value;
try {
await splitPdf(file, ranges, { outputName: 'extracted-pages.pdf' });
} catch (e) {
alert(e.message);
}
});
import { PDFDocument, degrees } from 'pdf-lib';
// Example: rotate the copied pages by 90 degrees clockwise
async function mergeWithRotation(files) {
const out = await PDFDocument.create();
for (const file of files) {
const src = await PDFDocument.load(await file.arrayBuffer());
const pages = await out.copyPages(src, Array.from({ length: src.getPageCount() }, (_, i) => i));
pages.forEach(p => {
// Only rotate landscape pages, example heuristic
const { width, height } = p.getSize();
if (width > height) p.setRotation(degrees(90));
out.addPage(p);
});
await new Promise(requestAnimationFrame);
}
const blob = new Blob([await out.save()], { type: 'application/pdf' });
return blob;
}
The File System Access API lets Chromium-based browsers stream-write large files directly to disk, which can reduce memory pressure.
// Chrome/Edge/Opera (desktop): prompt user for a save location
export async function saveWithPicker(uint8) {
if (!('showSaveFilePicker' in window)) throw new Error('File System Access API not available');
const handle = await window.showSaveFilePicker({
suggestedName: 'output.pdf',
types: [{ description: 'PDF', accept: { 'application/pdf': ['.pdf'] } }]
});
const writable = await handle.createWritable();
await writable.write(uint8); // uint8 is a Uint8Array from pdf-lib save()
await writable.close();
}
Note: pdf-lib currently produces an in-memory Uint8Array (not a streaming encoder). The API above avoids creating multiple Blob copies and writes directly to disk.
Offloading parsing and page copying to a Web Worker keeps the main thread responsive during large merges.
// worker.js
importScripts('https://cdn.skypack.dev/pdf-lib');
self.onmessage = async (e) => {
const { files } = e.data; // Expect Array of { name, buffer: ArrayBuffer }
const { PDFLib } = self; // from pdf-lib UMD
const { PDFDocument } = PDFLib;
const out = await PDFDocument.create();
for (const f of files) {
const src = await PDFDocument.load(f.buffer);
const indices = Array.from({ length: src.getPageCount() }, (_, i) => i);
const copied = await out.copyPages(src, indices);
copied.forEach(p => out.addPage(p));
}
const bytes = await out.save();
postMessage({ bytes }, [bytes.buffer]);
};
// main thread
const worker = new Worker('worker.js');
function readFiles(files) {
return Promise.all(files.map(async f => ({ name: f.name, buffer: await f.arrayBuffer() })));
}
async function mergeInWorker(files) {
const payload = await readFiles(files);
return new Promise((resolve, reject) => {
worker.onmessage = (e) => resolve(new Blob([e.data.bytes], { type: 'application/pdf' }));
worker.onerror = reject;
worker.postMessage({ files: payload }, payload.map(p => p.buffer));
});
}
Client-side tools dramatically shrink your attack surface. Here’s the concrete threat model and mitigations:
Example CSP header for a self-hosted build:
Content-Security-Policy: default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
Security note: If you add third-party analytics or error reporting, those scripts can observe environment details. For high-sensitivity workflows, either self-host analytics or remove them.
Client-side PDF processing is fast, but large inputs demand care. Recommendations:
await new Promise(requestAnimationFrame) inside long loops to keep the UI responsive and let the browser’s GC do its job.pdf-lib.save() returns.Uint8Array directly to disk to minimize additional memory copies.Rule of thumb: Most modern desktops can merge hundreds of pages quickly. For giant binders (thousands of pages), process in chunks, then merge the chunks.
Scanning creates image-only PDFs with no selectable text. Merging and splitting still work normally, but searching text will fail unless you apply OCR.
Build client-side PDF tools to be inclusive and efficient.
| Approach | Privacy | Speed | Install required | Best for |
|---|---|---|---|---|
| Online services | Low (uploads to remote servers) | Varies (depends on vendor queues and bandwidth) | No | Casual, non-sensitive workflows |
| Client-side in browser | High (no uploads) | High (device-speed, instant) | No | Security-conscious teams; fast one-off edits |
| Desktop apps | High (local) | High (native) | Yes | Heavy-duty workflows, batch automation, offline environments |
Is it safe to merge PDFs online?
Can I split a PDF without uploading it?
Why is my output PDF bigger than the inputs combined?
Why won’t my file open or merge?
Do bookmarks and annotations survive merging?
How many pages can I merge?
Does this work on iPhone/iPad?
How do I verify nothing left my device?
This section is informational and not legal advice.
Planned and optional enhancements for power users and dev teams:
This article was written and technically reviewed by our Senior SEO Content Strategist and engineering team specializing in secure, client-side developer tooling. We dogfood these techniques in our own browser-based PDF utilities, run internal threat modeling on each release, and test across major desktop and mobile browsers. Last reviewed: 2026‑06‑30.
What is client-side PDF processing?
How to merge PDFs in Chrome without uploading:
How to split a PDF by page range locally:
Is it safe to use in-browser PDF tools?
Do client-side tools meet privacy and residency requirements?
import { PDFDocument } from 'pdf-lib';
const mergeInput = document.getElementById('merge');
const splitInput = document.getElementById('split');
const ranges = document.getElementById('ranges');
function download(bytes, name) {
const blob = new Blob([bytes], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: name });
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
async function doMerge() {
const files = Array.from(mergeInput.files || []);
const out = await PDFDocument.create();
for (const f of files) {
const src = await PDFDocument.load(await f.arrayBuffer());
const idx = Array.from({ length: src.getPageCount() }, (_, i) => i);
const pages = await out.copyPages(src, idx);
pages.forEach(p => out.addPage(p));
}
download(await out.save(), 'merged.pdf');
}
async function doSplit() {
const file = splitInput.files && splitInput.files[0];
if (!file) return alert('Choose a file');
const src = await PDFDocument.load(await file.arrayBuffer());
const count = src.getPageCount();
const idx = (ranges.value || '1').split(',').flatMap(token => {
token = token.trim();
if (!token) return [];
if (token.includes('-')) {
const [a,b] = token.split('-').map(n => Math.max(1, Math.min(count, parseInt(n,10))));
if (a <= b) return Array.from({length:b-a+1}, (_,i)=> (a+i)-1);
return [];
}
const n = parseInt(token,10); if (n>=1 && n<=count) return [n-1]; return [];
});
if (!idx.length) return alert('No valid pages');
const out = await PDFDocument.create();
const pages = await out.copyPages(src, Array.from(new Set(idx)).sort((x,y)=>x-y));
pages.forEach(p => out.addPage(p));
download(await out.save(), 'split.pdf');
}
// Hook up to buttons in your UI
// document.getElementById('mergeBtn').onclick = doMerge;
// document.getElementById('splitBtn').onclick = doSplit;
If you need an auditable, local-first way to manage sensitive PDFs, client-side processing is the safest, fastest option available today. With the examples above, you can deploy a private, no-upload merge/split workflow in under an hour and scale up with WebAssembly for OCR or compression as your needs grow.
Can I run this completely offline?