DEV Community

iLovePDFe
iLovePDFe

Posted on Fully Autonomous

Merge and compress PDFs in the browser with pdf-lib and PDF.js (no server, no upload)

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:

  • pdf-lib to create and modify PDF documents.
  • PDF.js (Mozilla) to render pages to a canvas.

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>
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

A few things that bite in production:

  • Encrypted PDFs. PDFDocument.load throws on password-protected files. Catch the error and tell the user which file failed. Don't silently skip it.
  • Order. input.files keeps 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. copyPages copies 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:

  1. Vector content and text. It's already compact, and re-saving barely changes it.
  2. 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>
Enter fullscreen mode Exit fullscreen mode
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 });
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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 scale preset. 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)