DEV Community

Miller Elias
Miller Elias

Posted on

How PDF Merging Actually Works Under the Hood (And Why Most Implementations Mess It Up)

Merging PDF files sounds like a trivial Sunday afternoon project. Grab file A, grab file B, glue them together, download. How hard could it be?

If you've ever inspected what popular libraries actually do—or worse, tried to write a parser yourself—you know that PDF is not a stream of formatted text. It’s a 30-year-old PostScript-derived, semi-random-access object graph masquerading as a document format.

Let's walk through what actually happens when you merge two PDF files, why naive approaches destroy document fidelity, and how modern browser runtimes can handle the entire pipeline without uploading sensitive files to an external server.


The Naive Trap: Canvas Rasterization

Before getting into the internals, we have to talk about the anti-pattern that plagues far too many open-source projects and SaaS utilities: the rasterization pipeline.

It usually looks like this:

  1. Render each page of Document A onto an HTML5 <canvas> using pdf.js.
  2. Convert that canvas to a PNG or JPEG blob (canvas.toDataURL()).
  3. Re-encode those images into a brand-new PDF using a generator like jsPDF.
[Vector PDF] ──> [Rasterize to 72/150 DPI Bitmap] ──> [Re-wrap into new PDF]
Enter fullscreen mode Exit fullscreen mode

It works on a prototype demo, but in production, it's a disaster:

  • Text searchability is obliterated: Ctrl+F stops working because glyph strings were converted into flat pixels.
  • File sizes explode: A 200 KB contract with crisp vector typography suddenly turns into a 35 MB blob of compressed JPEGs.
  • Print degradation: Vector shapes and true type fonts scale infinitely; rasterized canvases become blurry pixel soup when sent to high-DPI office printers.
  • Metadata stripped: Hyperlinks, form fields, and bookmark outlines vanish.

To merge PDFs properly, you cannot treat pages as images. You must treat them as object graphs.


What a PDF File Actually Looks Like

At its core (as standardized in ISO 32000), a PDF is divided into four distinct regions:

┌──────────────────────────────────────────┐
│  1. Header (%PDF-1.7)                    │
├──────────────────────────────────────────┤
│  2. Body                                 │
│     1 0 obj << /Type /Catalog ... >>     │
│     2 0 obj << /Type /Pages ... >>       │
│     3 0 obj << /Type /Page ... >>        │
│     4 0 obj << /Length 284 >> stream ... │
├──────────────────────────────────────────┤
│  3. Cross-Reference Table (xref)          │
│     0000000000 65535 f                   │
│     0000000015 00000 n                   │
│     0000000128 00000 n                   │
├──────────────────────────────────────────┤
│  4. Trailer                              │
│     << /Size 5 /Root 1 0 R >>            │
│     startxref                            │
│     1024                                 │
│     %%EOF                                │
└──────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Notice the architecture: PDF readers do not read from top to bottom.

When a PDF engine opens a file:

  1. It seeks directly to the last 1,024 bytes to find the %%EOF marker and the startxref pointer.
  2. It reads the byte offset of the Cross-Reference Table (xref) or Cross-Reference Stream.
  3. The xref table tells the engine the exact physical byte positions of every indirect object (1 0 obj, 2 0 obj, etc.) in the file.
  4. It resolves the /Root dictionary (the Catalog), traverses the /Pages tree, and loads only what is needed to display page 1.

Every page is a node inside a tree structure, pointing to resources, content streams, and font dictionaries.


The Real Technical Hurdles of Merging

When you combine File A (10 pages) and File B (15 pages) into a single document, you cannot simply concatenate the byte buffers. Here is what makes combining them non-trivial.

1. Object Number ID Collisions

In File A:

4 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
Enter fullscreen mode Exit fullscreen mode

In File B:

4 0 obj
<< /Type /XObject /Subtype /Image /Width 600 /Height 400 ... >>
endobj
Enter fullscreen mode Exit fullscreen mode

Both documents have an object numbered 4 0 obj. If you put them in the same file without remapping, the parser hits an ID collision.

The merger must parse the indirect object table, maintain an ID translation map (SourceDocA_Obj4 -> TargetDoc_Obj18), and rewrite every indirect reference (4 0 R -> 18 0 R) across dictionaries, stream descriptors, and arrays.

2. The /Pages Hierarchy & Inherited Attributes

Pages in a PDF are not a flat array; they form a tree structure.

          /Pages (Root)
         /             \
    /Pages (Branch)    /Pages (Branch)
     /        \          /        \
  /Page      /Page    /Page      /Page
Enter fullscreen mode Exit fullscreen mode

Here is a common edge case: according to the PDF specification, attributes like /MediaBox (the page width/height), /Rotate, and /Resources can be defined on an ancestor /Pages node rather than on the individual /Page leaf.

If you extract a /Page dictionary from File A that relies on an inherited /Rotate 90 or an inherited font resource from its parent /Pages node, and you clone only the leaf node into the target document without flattening inherited properties, the page will render blank or rotated incorrectly.

A solid merger must traverse up the source document's tree, resolve any inherited attributes, and explicitly hoist them into the newly copied page dictionary.

3. Font Subsets and Duplication

When software like InDesign, Word, or LaTeX compiles a PDF, it rarely embeds full font files. Instead, it embeds font subsets—packaging only the exact glyphs used in the document (often prefixed with a random tag, like XYZABC+Roboto-Regular).

If File A and File B both use Roboto-Regular, their subset dictionaries will have entirely different glyph mapping tables (CMap / ToUnicode).

You cannot blindly merge or deduplicate /Font objects simply because their base names match. Each font subset must either remain isolated as an independent indirect object or undergo full CMap reconciliation to avoid turning characters into garbled question marks.


Doing It Right in Modern JavaScript

Thanks to libraries like pdf-lib, we don't have to write raw byte stream re-mappers by hand for standard workflows. pdf-lib handles object re-indexing, indirect reference translation, and stream cloning purely in memory.

Here is a lean, battle-tested pattern for combining multiple PDF files in modern TypeScript:

import { PDFDocument } from 'pdf-lib';

interface MergeProgressCallback {
  (current: number, total: number): void;
}

/**
 * Merges multiple PDF ArrayBuffers into a single Uint8Array
 * without re-encoding or rasterizing vector streams.
 */
export async function mergePdfBuffers(
  pdfBuffers: ArrayBuffer[],
  onProgress?: MergeProgressCallback
): Promise<Uint8Array> {
  // 1. Create a pristine target document
  const mergedDoc = await PDFDocument.create();

  for (let i = 0; i < pdfBuffers.length; i++) {
    const buffer = pdfBuffers[i];

    // 2. Load the source PDF into an isolated object graph
    const srcDoc = await PDFDocument.load(buffer, {
      ignoreEncryption: true,
      parseSpeed: 1, // Balance between memory footprint and parsing speed
    });

    // 3. Resolve page indices and copy references
    const pageIndices = srcDoc.getPageIndices();
    const copiedPages = await mergedDoc.copyPages(srcDoc, pageIndices);

    // 4. Add pages to the target tree while preserving original MediaBox geometry
    for (const page of copiedPages) {
      mergedDoc.addPage(page);
    }

    if (onProgress) {
      onProgress(i + 1, pdfBuffers.length);
    }
  }

  // 5. Serialize the unified object graph and reconstruct the xref table
  return await mergedDoc.save({
    useObjectStreams: true, // Compresses indirect objects into streams
  });
}
Enter fullscreen mode Exit fullscreen mode

Why this approach works:

  1. Zero rasterization: copyPages transfers raw /Contents streams and referenced font dictionaries directly into the new document catalog.
  2. Dimension preservation: If File 1 is an A4 portrait contract and File 2 is an A3 landscape spreadsheet, each page's native /MediaBox and /CropBox remain untouched. They coexist naturally without artificial margins.
  3. Selectable text: Vector glyph coordinates and /ToUnicode mapping streams stay fully intact.

Memory Management in the Browser

Running this inside Node.js or a Go worker is relatively straightforward because server processes can allocate large heaps. Doing this entirely inside a user's browser tab is where real-world constraints kick in.

A modern browser tab typically hits an Out-Of-Memory (OOM) ceiling when JavaScript heap allocations push past 1.5 GB to 2 GB (especially on mobile WebKit).

When merging 10 files that are 50 MB each, you aren't just handling 500 MB of data:

  • Each file starts as a File or Blob.
  • It gets unpacked into an ArrayBuffer.
  • The parser converts byte streams into JavaScript objects (dictionaries, arrays, numbers).
  • The new document builds an internal intermediate representation.
  • The serializer creates a final Uint8Array.

If you hold references to all parsed source documents in a single array simultaneously, your tab will crash.

Practical Optimizations:

  1. Sequential Processing & Garbage Collection: Load, copy, and immediately let the source document fall out of scope before loading the next one.
   // ❌ Bad: Keeps all source docs in memory at once
   const loadedDocs = await Promise.all(buffers.map(b => PDFDocument.load(b)));

   // ✅ Good: One at a time, allowing GC to collect completed source documents
   for (const buf of buffers) {
     const doc = await PDFDocument.load(buf);
     const pages = await mergedDoc.copyPages(doc, doc.getPageIndices());
     pages.forEach(p => mergedDoc.addPage(p));
     // 'doc' can now be garbage-collected
   }
Enter fullscreen mode Exit fullscreen mode
  1. Revoke Object URLs Promptly: When triggering the file download:
   const blob = new Blob([mergedBytes], { type: 'application/pdf' });
   const url = URL.createObjectURL(blob);

   const a = document.createElement('a');
   a.href = url;
   a.download = 'merged-document.pdf';
   a.click();

   // Don't leak the blob in the browser's internal URL store
   setTimeout(() => URL.revokeObjectURL(url), 10_000);
Enter fullscreen mode Exit fullscreen mode
  1. Offloading to Web Workers: Parsing heavy documents can block the main UI thread for several seconds, freezing CSS animations and user inputs. Moving the PDFDocument.load and mergedDoc.save calls into a dedicated Web Worker keeps your UI running at a smooth 60 FPS while background threads crunch byte arrays.

Handling Tricky Edge Cases

If you are implementing this in production, keep these three edge cases on your radar:

  • Linearized PDFs ("Fast Web View"): Linearized PDFs store a primary hint stream near the front of the file so web viewers can show page 1 before downloading the full file. When merging, linearization tables are invalid and must be stripped; the target file needs a standard xref table reconstruction.
  • AcroForms (Fillable Forms): If Document A has a text field called first_name and Document B also has a field called first_name, merging them naively can cause field values to overwrite each other. In a production pipeline, form fields either need unique namespace prefixes or should be flattened prior to merging depending on the use case.
  • Encrypted Documents: Even if a PDF doesn't ask for a password to open, it might carry an "Owner Password" with permission flags that restrict content extraction. Handling this gracefully requires checking isEncrypted flags and notifying the user upfront.

Wrapping Up

Building tools that touch PDFs requires respecting the format's architecture. The moment you step away from lossy image-conversion tricks and start working directly with object trees and byte streams, you get results that are fast, lightweight, and faithful to the original documents.

If you want to see this kind of client-side stream cloning in action without sending documents through a remote cloud queue, you can try out the implementation over at VersaPDF's Merge Tool. It processes the entire pipeline locally in the browser sandbox, illustrating how much heavy lifting modern web runtimes can achieve.

What edge cases have you run into when working with binary document formats in the browser? Let's discuss in the comments below.

Top comments (0)