Skip to main content

Command Palette

Search for a command to run...

Building a Zero-Upload PDF Merger: Client-Side Binary Stitching with WebAssembly and ArrayBuffers

How to merge multi-megabyte PDF files inside the browser sandbox without server dependencies or memory leaks.

Updated
•5 min read•View as Markdown
Building a Zero-Upload PDF Merger: Client-Side Binary Stitching with WebAssembly and ArrayBuffers

For over a decade, web-based document utilities have relied on a classic client-server model: users upload documents over HTTP to a cloud cluster (typically running headless LibreOffice, Ghostscript, or Poppler), the server processes the job, and the user downloads the result.

While simple to implement, this architecture carries two severe drawbacks: network latency/bandwidth costs for large files, and inherent data privacy vulnerabilities when dealing with regulated documents (GDPR, HIPAA, SOC 2).

With modern browser runtimes, optimized V8 JavaScript engines, and WebAssembly, heavy document manipulation can now run entirely within the user's browser sandbox.

In this deep dive, we examine how to stitch multiple PDF binary streams locally in-memory, manipulate low-level cross-reference tables (XREFs), and handle documents exceeding 100MB without exhausting the browser’s heap memory.

1. The Anatomy of a PDF at the Binary Level

To understand how to merge documents without rendering them to canvases or uploading them, we must first look at the PDF file structure defined by ISO 32000-1.

A PDF is essentially an object graph serialized as ASCII/binary bytes. It consists of four distinct sections:

+---------------------------------------------------+
|  1. Header: %PDF-1.7                              |
+---------------------------------------------------+
|  2. Body: Indirect Objects                        |
|     (Catalog, Pages, Streams, Fonts, Metadata)    |
+---------------------------------------------------+
|  3. Cross-Reference Table (XREF Table)            |
|     (Byte offsets for every object in the Body)   |
+---------------------------------------------------+
|  4. Trailer: Root pointer + Offset to XREF + %%EOF|
+---------------------------------------------------+

When combining two PDFs (e.g., DocA.pdf and DocB.pdf), a naive byte-concatenation (cat docA docB > merged.pdf) fails immediately. Each document has its own independent object ID numbering starting from 1 0 R and its own XREF byte offset table. Merging requires:

  1. Parsing the trailer to find the document Catalog and Page Tree.
  2. Re-indexing object IDs from Document B to avoid namespace collisions with Document A.
  3. Updating internal reference pointers (e.g., parent pointers in the Page Tree).
  4. Recalculating byte offsets for the combined XREF table and writing a new trailer dictionary.

2. In-Memory Pipeline: From File Blob to ArrayBuffer

To maintain zero server dependencies, the entire execution flow lives inside an event-driven browser pipeline:

[ User File Input (Blob) ]
           │
           ▼
[ FileReader API / blob.arrayBuffer() ]
           │
           ▼
[ Uint8Array TypedArrays in Local V8 Heap ]
           │
           ▼
[ Object Graph Merging (pdf-lib / WASM Engine) ]
           │
           ▼
[ Serialized Uint8Array ]
           │
           ▼
[ URL.createObjectURL(new Blob(...)) -> Direct Local Download ]

Reading Files Efficiently

Instead of traditional Base64 serialization (which incurs an immediate 33% memory overhead), modern browser runtimes allow direct typed buffer extraction:

async function readPdfBuffer(file: File): Promise<Uint8Array> {
  // Using native web Streams / ArrayBuffer for zero-copy parsing
  const arrayBuffer = await file.arrayBuffer();
  return new Uint8Array(arrayBuffer);
}

3. The Page Tree Merging Algorithm

When copying pages across documents, we must avoid re-encoding or rasterizing embedded raster images or font descriptors. Rasterizing vector streams into canvas pixels degrades visual fidelity and balloons the file size.

Instead, we copy the Page Dictionary and its referenced content streams as raw indirect objects.

Here is a simplified architectural pattern using modern client-side engines (such as pdf-lib):

import { PDFDocument } from 'pdf-lib';

async function mergePdfsLocally(pdfBuffers: Uint8Array[]): Promise<Uint8Array> {
  // 1. Create a pristine target container in local memory
  const mergedDoc = await PDFDocument.create();

  for (const buffer of pdfBuffers) {
    // 2. Load the source PDF into an isolated parse tree
    const srcDoc = await PDFDocument.load(buffer, {
      ignoreEncryption: false,
      // Parse lazy streams to save initialization ticks
      updateMetadata: false,
    });

    // 3. Extract the list of page indices
    const pageIndices = srcDoc.getPageIndices();

    // 4. Low-level deep-copy: transfers page objects, font references, 
    // and content streams while assigning fresh, non-colliding Object IDs
    const copiedPages = await mergedDoc.copyPages(srcDoc, pageIndices);

    // 5. Wire copied pages into the master catalog's /Pages node
    for (const page of copiedPages) {
      mergedDoc.addPage(page);
    }
  }

  // 6. Serialize the unified object graph and compute the new XREF table
  const mergedPdfBytes = await mergedDoc.save();
  return mergedPdfBytes;
}

4. Tackling the Client-Side Memory Bottleneck

Running heavy document tasks on mobile devices or lower-spec laptops poses a major challenge: Browser Tab Crash (OOM - Out of Memory).

In Chrome/V8, a single 32-bit worker context or constrained tab may have strict heap limits. If a user drops ten 80MB scanned PDF files simultaneously, naive processing will attempt to load 800MB of raw bytes + internal AST representations, triggering an instant memory blowout.

Optimization Strategies:

  1. Sequential Worker Processing: Never decode all input files concurrently. Load, extract, and append one document at a time.
  2. Explicit Dereferencing for Garbage Collection: Nullify intermediate buffers (srcDoc = null, buffer = null) immediately after copyPages completes to hint the V8 garbage collector before the next iteration.
  3. Chunked Blob Generation: When generating the final download, construct the binary blob directly from typed chunks to avoid a redundant memory clone:
function triggerClientSideDownload(data: Uint8Array, fileName: string) {
  const blob = new Blob([data], { type: 'application/pdf' });
  const objectUrl = URL.createObjectURL(blob);
  
  const link = document.createElement('a');
  link.href = objectUrl;
  link.download = fileName;
  document.body.appendChild(link);
  link.click();
  
  // Prevent memory leaks by revoking the blob URL
  setTimeout(() => {
    URL.revokeObjectURL(objectUrl);
    link.remove();
  }, 1000);
}

5. Security & Verification: The Air-Gapped Test

One of the greatest developer advantages of this architecture is testable client-side isolation:

  1. Load the web interface.
  2. Disconnect Wi-Fi or enable Airplane Mode.
  3. Open DevTools -> Network Tab.
  4. Drop two multi-megabyte PDFs and trigger the merge operation.

Because binary serialization runs directly in V8 memory, zero outbound packets (0 bytes transferred) are emitted, and the merged document downloads instantaneously.

For engineers looking to inspect a production implementation of zero-upload client-side workflows without paywalls, you can reference the live implementation on VersaPDF's Merge Tool.

Conclusion

Browser capabilities have advanced past simple DOM rendering. By leveraging typed arrays, modern WebAssembly bindings, and direct binary stream manipulation, developers can replace costly, privacy-compromising cloud processing pipelines with instantaneous, client-side web architectures.