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.

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:
- Parsing the trailer to find the document Catalog and Page Tree.
- Re-indexing object IDs from Document B to avoid namespace collisions with Document A.
- Updating internal reference pointers (e.g., parent pointers in the Page Tree).
- 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:
- Sequential Worker Processing: Never decode all input files concurrently. Load, extract, and append one document at a time.
- Explicit Dereferencing for Garbage Collection: Nullify intermediate buffers (
srcDoc = null,buffer = null) immediately aftercopyPagescompletes to hint the V8 garbage collector before the next iteration. - 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:
- Load the web interface.
- Disconnect Wi-Fi or enable Airplane Mode.
- Open DevTools -> Network Tab.
- 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.



