Most "free PDF tools" upload your file to a server, process it, and send it back. For a contract, a bank statement or a scanned ID, that is a real privacy cost, and it isn't necessary. Modern browsers can do most everyday PDF jobs locally.
In this post I'll show the two operations people ask for most, merge and compress, using two libraries:
No backend, no build step. Plain <script> tags work.
Setup
<input type="file" id="files" accept="application/pdf" multiple>
<button id="merge">Merge</button>
<script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
1. Merging PDFs
Merging is the easy one. You load each file, copy its pages into a new document, and save.
const { PDFDocument } = PDFLib;
async function mergePdfs(files) {
const out = await PDFDocument.create();
for (const file of files) {
const bytes = await file.arrayBuffer();
const src = await PDFDocument.load(bytes, { ignoreEncryption: false });
const pages = await out.copyPages(src, src.getPageIndices());
pages.forEach((p) => out.addPage(p));
}
// Object streams pack many small objects together and often save 5–15%.
return out.save({ useObjectStreams: true });
}
document.getElementById('merge').onclick = async () => {
const files = [...document.getElementById('files').files];
const bytes = await mergePdfs(files);
download(bytes, 'merged.pdf');
};
function download(bytes, name) {
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
const a = Object.assign(document.createElement('a'), { href: url, download: name });
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
A few things that bite in production:
-
Encrypted PDFs.
PDFDocument.loadthrows on password-protected files. Catch the error and tell the user which file failed. Don't silently skip it. -
Order.
input.fileskeeps selection order on most browsers, but users expect drag-to-reorder. Keep your own array and let them sort it. -
Memory. Every file is held in memory as an
ArrayBuffer. A few hundred MB is fine on desktop, but on low-end phones you should cap the total size and say so up front. -
Forms and bookmarks.
copyPagescopies page content and annotations, but not the document outline (bookmarks). If users need bookmarks preserved, that's extra work.
2. "Compressing" a PDF: what's actually possible
This is where most tools oversell. A PDF is mostly two things:
- Vector content and text. It's already compact, and re-saving barely changes it.
- Embedded images, usually scans or photos. This is where the megabytes live.
pdf-lib can re-save a document with object streams (the useObjectStreams flag above). That's lossless, and on text-heavy files it typically saves a few percent. It cannot re-encode images.
To really shrink a scanned PDF you have to rasterize. You render each page to a canvas, encode it as JPEG at a lower quality, and build a new PDF from those images. That is lossy, and it turns selectable text into pixels, so only offer it when the input is image-heavy.
<script src="https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
</script>
async function compressByRaster(file, { scale = 1.5, quality = 0.6 } = {}) {
const data = new Uint8Array(await file.arrayBuffer());
const src = await pdfjsLib.getDocument({ data }).promise;
const out = await PDFLib.PDFDocument.create();
for (let i = 1; i <= src.numPages; i++) {
const page = await src.getPage(i);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
const blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', quality));
const jpg = await out.embedJpg(await blob.arrayBuffer());
// Keep the original page size in PDF points, so it prints the same.
const { width, height } = page.getViewport({ scale: 1 });
const p = out.addPage([width, height]);
p.drawImage(jpg, { x: 0, y: 0, width, height });
page.cleanup();
canvas.width = canvas.height = 0; // free GPU memory on mobile
}
return out.save({ useObjectStreams: true });
}
The two knobs that matter:
| Setting | Effect |
|---|---|
scale |
Render resolution. 1.0 ≈ 72 dpi (blurry), 1.5 ≈ 108 dpi (readable), 2.0 ≈ 144 dpi (sharp) |
quality |
JPEG quality 0–1. 0.6 is a good default for scans; below 0.4 text edges get muddy |
Always check the result is smaller
Rasterizing a text-only PDF can make it bigger, because a page of vector text is tiny while a JPEG of it isn't. So compare sizes and fall back to the original:
const compressed = await compressByRaster(file);
const result = compressed.byteLength < file.size ? compressed : new Uint8Array(await file.arrayBuffer());
Show the user both sizes ("2.4 MB → 610 KB") so they can decide. A tool that silently returns a larger file loses trust fast.
Performance notes
- Render pages one at a time, as above, rather than all at once with
Promise.all. Parallel canvases blow up memory on phones. - PDF.js runs parsing in a Web Worker, so the UI stays responsive. Still, show a progress indicator per page.
- For 100+ page documents, give users a lower
scalepreset. Most people compressing a scan want "small enough to email", not archival quality.
Why do it client-side at all?
- Privacy: the file never leaves the device, which is the main reason users care.
- Cost: no servers processing gigabytes of PDFs.
- Speed: there's no upload or download wait, which matters on slow mobile connections.
The trade-off is that heavy jobs like OCR or Office conversion are harder in the browser. For merge, split, rotate, reorder and image-based compression, though, the browser is more than enough.
We use this approach for the merge and compress tools at iLovePDFe, a set of free PDF tools that run in the browser. If you build something similar, I'd love to hear how you handle encrypted files and very large scans.
This article was written with AI assistance.
Top comments (0)