Back to Insights
pdf engineering

How to Build a Custom PDF Assembly Pipeline using JavaScript and WebAssembly

2026-06-05
22 min read
Engineering Digest

A deep technical walkthrough for developers on building a client-side PDF assembly pipeline in JavaScript. Learn how to read File objects, call a WASM PDF library, stitch pages, generate Blob URLs, and eliminate ₹0 server costs — all while keeping every document on the user's own device.

A fully client-side PDF assembly pipeline requires no backend server, no cloud storage, and costs ₹0 in infrastructure.
WebAssembly (WASM) PDF libraries run compiled C++ or Rust engines in the browser sandbox at near-native speeds.
The JavaScript File API, ArrayBuffer, Uint8Array, and Blob URL are the four primitives that glue the pipeline together.
Web Workers offload heavy WASM computation off the main thread, keeping the UI smooth and responsive.
Content Roadmap

Building software that processes user documents is one of the most privacy-sensitive engineering challenges in modern web development. For years, the accepted pattern was straightforward but deeply flawed: send the file to a server, do the work there, and send the result back. That model is expensive, introduces data sovereignty risks, and creates latency on every operation. This guide documents a different approach — one that executes every step of a PDF assembly pipeline inside the user's own browser tab, without transmitting a single byte of document data across the network.

We will build a complete, production-grade PDF Assembly Pipeline in JavaScript, backed by a WebAssembly (WASM) PDF library. You will learn how to read multiple File objects from a drag-and-drop interface, allocate memory inside a WASM module heap, copy raw PDF bytes across the JS-WASM boundary, invoke the native merge engine, retrieve the assembled output, construct a downloadable Blob URL, and clean up all memory — all entirely client-side. We'll look at how this architecture applies to real-world Indian use cases: fintech KYC pipelines, UIDAI Aadhaar document bundles, NSDL PAN card attachments, and Parivahan DL/RC submissions.

If you want to skip straight to the working implementation, try MojoDocs PDF Merger — a production deployment of exactly this pipeline. For the engineering story behind the WASM compilation process, read our companion article on The Engineering Behind MojoDocs WebAssembly.

1. Why Build a Client-Side PDF Assembly Pipeline at All?

Let's begin with a fundamental question: why go through the complexity of WASM and client-side memory management when you could simply spin up a Node.js microservice, use pdf-lib on the server, and call it a day?

The answer sits at the intersection of three converging forces: data sovereignty, economics, and performance.

Data Sovereignty: The Document Privacy Imperative

India's digital ecosystem is built on a small set of identity documents that carry enormous legal weight. When a citizen needs to apply for a home loan, register a vehicle, file for a government scheme, or complete KYC for a new bank account, they are required to assemble a specific bundle of scanned documents:

  • Aadhaar Card (UIDAI): Contains a 12-digit unique biometric identifier, full name, date of birth, gender, and current address. Issued under the UIDAI framework, it is used as the primary proof of identity across hundreds of services.
  • PAN Card (NSDL/UTI): A 10-character alphanumeric tax identifier linking the individual to their entire financial history — credit score, income tax returns, and property transactions.
  • Driving Licence / Registration Certificate (Parivahan, MoRTH): Vehicle registration documents, often required for loans or insurance KYC, generated by the Ministry of Road Transport and Highways' Parivahan portal.
  • Passport (MEA): Issued by the Ministry of External Affairs. Required for international transactions, FOREX, and premium financial products.
  • Bank Statements (6-12 months): Multi-page PDFs from any of hundreds of Indian banks, used for credit underwriting in home loans, MSME loans, and credit cards.

When a fintech or lending application asks users to merge these documents into a single KYC PDF bundle, it is asking users to bring together the most sensitive documents they own into one file. If this merge operation is performed on a cloud server — even one operated by the fintech company itself — that combined document is now transmitted over the network, stored in temporary processing memory, and potentially written to backup storage or logs. The attack surface is enormous. A single data leak from that storage can expose an individual's complete financial and biometric profile.

A client-side assembly pipeline changes the threat model fundamentally. The merge happens inside the browser's sandboxed memory. The merged file is never transmitted. The server receives only the final output after the user has reviewed it and explicitly clicked "Submit" — or in many cases, the user downloads the merged file and submits it through a separate, controlled upload channel. The browser sandbox is the server. The user's RAM is the temporary storage. Closing the tab is the deletion policy.

Economics: ₹0 Server-Side Processing Cost

Let's talk about money. If you are building a document-processing feature for a SaaS platform, every document merge operation that happens on your server represents a direct infrastructure cost. Let's model this for a mid-size Indian fintech with 50,000 active users per month, each performing an average of 3 document assembly operations:

Method Cost Privacy
Managed Cloud PDF API (e.g., Adobe PDF Services, iLovePDF API) ₹3 to ₹8 per document operation — ₹4.5 to ₹12 lakh/month for 1.5L operations Low — documents uploaded to third-party servers
Self-Hosted Server (AWS EC2, c6i.2xlarge, dedicated PDF workers) ₹35,000 to ₹85,000/month for compute + egress + storage Medium — data stays in your cloud but transits your network
Serverless Functions (AWS Lambda, with pdf-lib) ₹800 to ₹3,000/month for compute + ₹1,500 to ₹4,000/month for S3 temp storage Medium — cold starts, latency spikes, temp file risks
Client-Side WASM Pipeline (MojoDocs Pattern) ₹0 for processing — only static WASM asset CDN costs (₹500 to ₹2,000/month flat) Maximum — 100% local, no server, no transit, no storage

For a fintech company serving 50,000 users monthly, switching from a managed cloud PDF API to a client-side WASM pipeline can eliminate ₹5 to ₹12 lakh per month in direct processing costs — and that scales proportionally as the user base grows. The WASM module is a static asset delivered via CDN once and then cached in the user's browser. Subsequent operations cost the platform nothing. For a startup burning through its Series A, this is a structural engineering decision worth millions over a two-year runway.

Individual developers and small agencies fare even better. Instead of subscribing to Adobe Acrobat Pro at approximately ₹1,593 per month or a cloud PDF merger SaaS at ₹450 to ₹750 per month, a developer who builds or uses a client-side WASM pipeline pays nothing — not even a subscription.

2. Architecture Overview: The Four-Stage Pipeline

A production-grade client-side PDF assembly pipeline consists of four distinct stages that execute sequentially within a Web Worker thread. Isolating the pipeline in a Worker ensures that heavy WASM computation never blocks the browser's main thread, keeping the UI fluid and animations smooth regardless of the file size being processed.

The four stages are:

  1. File Ingestion: The user selects multiple PDF files via drag-and-drop or a file picker. The browser's File API returns a FileList. Each File object is converted to an ArrayBuffer via the FileReader API or the newer file.arrayBuffer() Promise-based method.
  2. WASM Module Initialization: A compiled WASM binary (the PDF engine) is loaded into the Web Worker's execution context. The engine exposes a set of exported functions: create_merger, add_pdf_bytes, finalize_merge, and free_result. These correspond to heap allocation, data ingestion, computation, and memory cleanup.
  3. Memory Bridge and Engine Execution: For each PDF file, the pipeline allocates memory inside the WASM heap using the engine's exported allocator, copies the Uint8Array bytes into the allocated memory block, and calls the add_pdf_bytes function. Once all files are added, the finalize_merge call produces the merged output at a known memory address within the WASM heap.
  4. Result Extraction and Blob URL Generation: JavaScript reads the merged PDF bytes from the WASM heap memory back into a JavaScript Uint8Array. This array is wrapped in a Blob with MIME type application/pdf, from which a temporary Object URL is created. This URL is used as the href of a programmatically clicked anchor element to trigger an immediate browser download. After download, the Object URL is revoked to free browser memory.

Pro Tip: Always run your PDF assembly pipeline inside a Worker, not on the main thread. Even with a fast WASM module, parsing and merging large PDF files (especially scanned bundles with 10+ pages at 300 DPI) can take 2-8 seconds on mobile CPUs. If this runs on the main thread, the browser will freeze and may display a "This page is unresponsive" warning to the user.

3. Setting Up the Project: Dependencies and WASM Loading

Before writing any pipeline code, you need a JavaScript-accessible WASM PDF library. There are several mature options depending on your licensing and technical requirements:

  • pdf-lib (JavaScript, MIT License): A pure JavaScript PDF manipulation library. No WASM required, but performance is lower for large files. Excellent for lightweight merging operations.
  • PDFium compiled to WASM: Google's open-source PDF engine (used in Chrome's built-in PDF viewer), compiled to WASM using Emscripten. Extremely fast and feature-complete, but the compiled binary is larger (~15MB).
  • mupdf.js (WASM port of MuPDF, AGPL-3.0): MuPDF is one of the fastest PDF rendering and manipulation engines available. Its WASM port is actively maintained and exposes a clean JavaScript API.
  • Rust-based custom engine (compiled with wasm-pack): If you need maximum control, you can write a Rust PDF parser using the lopdf crate and compile it to WASM using wasm-pack. This is the approach MojoDocs uses for its core engine.

For this walkthrough, we'll demonstrate the pattern using pdf-lib (for its zero-dependency, pure-JS simplicity) and then show the WASM memory bridge pattern that applies when using a compiled binary engine like MuPDF or a custom Rust module. This gives you both the beginner and the advanced path.

Project Structure


/pdf-pipeline-demo
  /public
    /wasm
      pdf-engine.wasm          # Compiled WASM binary
      pdf-engine.js            # Emscripten JS glue or wasm-bindgen output
  /src
    main.ts                    # UI, drag-and-drop, Worker communication
    pdf.worker.ts              # Web Worker: pipeline execution
    pdf-bridge.ts              # WASM memory bridge utilities
    types.ts                   # Shared type definitions
  package.json
  tsconfig.json
    

Installing pdf-lib for the Simple Pipeline


npm install pdf-lib
# Or with bun:
bun add pdf-lib
    

4. Stage 1 — File Ingestion: Reading PDFs with the File API

The browser's File API is the secure gateway through which local files enter your application. Files are only accessible after an explicit user gesture — either a file picker dialog or a drag-and-drop event. The browser enforces this security boundary to prevent malicious scripts from silently reading arbitrary files from the user's disk.


// src/main.ts

interface PipelineFile {
  name: string;
  bytes: Uint8Array;
  sizeKB: number;
}

/**
 * Converts a File object to a PipelineFile with raw bytes.
 * Uses the modern file.arrayBuffer() Promise API (supported in all modern browsers).
 * Falls back to FileReader for environments that need it.
 */
async function readFileAsUint8Array(file: File): Promise<PipelineFile> {
  // Modern API: returns a Promise<ArrayBuffer> directly
  const arrayBuffer = await file.arrayBuffer();
  const bytes = new Uint8Array(arrayBuffer);

  return {
    name: file.name,
    bytes,
    sizeKB: Math.round(bytes.byteLength / 1024),
  };
}

/**
 * Handles drag-and-drop events on the drop zone element.
 * Filters for PDF MIME type and reads all files concurrently.
 */
async function handleDrop(event: DragEvent): Promise<PipelineFile[]> {
  event.preventDefault();
  event.stopPropagation();

  const items = event.dataTransfer?.items;
  if (!items) return [];

  const pdfFiles: File[] = [];

  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    // Only accept PDF files
    if (item.kind === 'file' && item.type === 'application/pdf') {
      const file = item.getAsFile();
      if (file) pdfFiles.push(file);
    }
  }

  // Read all files concurrently using Promise.all
  // This is significantly faster than reading them sequentially
  const pipelineFiles = await Promise.all(pdfFiles.map(readFileAsUint8Array));
  return pipelineFiles;
}
    

Pro Tip: For KYC pipelines where documents must be submitted in a specific legal order (e.g., Aadhaar first, then PAN, then address proof), use a sortable drag interface (like @dnd-kit/core or SortableJS) that lets users reorder documents before initiating the merge. The order in which you call add_pdf_bytes determines the final page order — there is no "reorder after merge" step.

5. Stage 2 — Simple Pipeline with pdf-lib (Pure JavaScript)

For lightweight merging operations, pdf-lib provides a clean, pure-JavaScript API that runs entirely in-browser with no WASM required. This is the right choice for document bundles under 20MB with fewer than 50 pages.


// src/pdf.worker.ts (running inside a Web Worker)

import { PDFDocument } from 'pdf-lib';

// Listen for messages from the main thread
self.onmessage = async (event: MessageEvent) => {
  const { files } = event.data as { files: Array<{ name: string; bytes: Uint8Array }> };

  try {
    const mergedBytes = await assemblePDFs(files);

    // Transfer the ArrayBuffer back to the main thread using zero-copy transfer
    // This avoids cloning the entire buffer, which is critical for large outputs
    self.postMessage(
      { success: true, result: mergedBytes.buffer },
      [mergedBytes.buffer] // Transfer ownership (zero-copy)
    );
  } catch (error) {
    self.postMessage({
      success: false,
      error: error instanceof Error ? error.message : 'Unknown assembly error',
    });
  }
};

async function assemblePDFs(
  files: Array<{ name: string; bytes: Uint8Array }>
): Promise<Uint8Array> {
  // Create the output PDF document
  const mergedPdf = await PDFDocument.create();

  for (const file of files) {
    // Load each source PDF from its raw bytes
    const sourcePdf = await PDFDocument.load(file.bytes, {
      // Ignore encryption errors for documents with lightweight security
      ignoreEncryption: false,
      // Update modification date to current timestamp
      updateMetadata: true,
    });

    // Get the total page count of the source document
    const pageCount = sourcePdf.getPageCount();

    // Build an array of all page indices: [0, 1, 2, ..., pageCount - 1]
    const pageIndices = Array.from({ length: pageCount }, (_, i) => i);

    // Copy all pages from the source PDF into the merged document
    // copyPages handles cross-document font and image resource resolution automatically
    const copiedPages = await mergedPdf.copyPages(sourcePdf, pageIndices);

    // Add each copied page to the merged document in order
    for (const page of copiedPages) {
      mergedPdf.addPage(page);
    }

    console.log(`[PDF Worker] Added ${pageCount} pages from "${file.name}"`);
  }

  // Serialize the merged PDF to bytes
  // The 'save' method returns a Uint8Array of the complete PDF binary
  const mergedBytes = await mergedPdf.save({
    // Use object streams for more compact encoding (PDF 1.5+)
    useObjectStreams: true,
    // Add creation date metadata
    addDefaultPage: false,
  });

  console.log(`[PDF Worker] Assembly complete. Output size: ${Math.round(mergedBytes.byteLength / 1024)} KB`);

  return mergedBytes;
}
    

Invoking the Worker from the Main Thread


// src/main.ts (continued)

let pdfWorker: Worker | null = null;

function getPDFWorker(): Worker {
  if (!pdfWorker) {
    // Create a Web Worker from the compiled worker module
    // Vite and Next.js both support this `?worker` import syntax
    pdfWorker = new Worker(new URL('./pdf.worker.ts', import.meta.url), {
      type: 'module',
    });
  }
  return pdfWorker;
}

async function mergeFiles(files: PipelineFile[]): Promise<void> {
  return new Promise((resolve, reject) => {
    const worker = getPDFWorker();

    // Prepare transferable data
    // We transfer the underlying ArrayBuffers for zero-copy efficiency
    const transferableFiles = files.map((f) => ({
      name: f.name,
      bytes: f.bytes,
    }));

    const buffers = transferableFiles.map((f) => f.bytes.buffer);

    worker.onmessage = (event: MessageEvent) => {
      const { success, result, error } = event.data;

      if (!success) {
        reject(new Error(error));
        return;
      }

      // result is the transferred ArrayBuffer
      const mergedBytes = new Uint8Array(result);
      downloadMergedPDF(mergedBytes, 'merged-documents.pdf');
      resolve();
    };

    worker.onerror = (event: ErrorEvent) => {
      reject(new Error(`Worker error: ${event.message}`));
    };

    // Post the files to the worker with transferable buffers
    worker.postMessage({ files: transferableFiles }, buffers);
  });
}

/**
 * Creates a temporary Blob URL and triggers a browser download.
 * The URL is immediately revoked after the click to free memory.
 */
function downloadMergedPDF(bytes: Uint8Array, filename: string): void {
  // Create a Blob with the PDF MIME type
  const blob = new Blob([bytes], { type: 'application/pdf' });

  // Generate a temporary URL pointing to the in-memory blob
  // This URL is local to the browser and never transmitted over the network
  const objectURL = URL.createObjectURL(blob);

  // Create an invisible anchor element and trigger a download click
  const anchor = document.createElement('a');
  anchor.href = objectURL;
  anchor.download = filename;
  anchor.style.display = 'none';
  document.body.appendChild(anchor);
  anchor.click();

  // Clean up: remove anchor and revoke the Object URL to free browser memory
  document.body.removeChild(anchor);

  // Revoke after a short delay to ensure the download has started
  setTimeout(() => URL.revokeObjectURL(objectURL), 1000);

  console.log('[Main Thread] Download initiated. Blob URL revoked after 1s.');
}
    

6. Stage 3 — Advanced WASM Memory Bridge Pattern

When your pipeline needs to handle files larger than 50MB, process 100+ page documents, or run on memory-constrained mobile devices, you need to move beyond pure JavaScript and interface directly with a compiled WASM binary. This requires working with the WASM module's linear memory space — a contiguous block of bytes that serves as the module's heap.

The following pattern is directly adapted from MojoDocs' internal PDF engine bridge. It assumes a WASM module compiled from Rust using wasm-bindgen or a C++ module compiled with Emscripten, exposing at minimum: wasm_alloc(size: u32) -> u32, wasm_free(ptr: u32), create_merger() -> u32, add_pdf(merger_ptr: u32, pdf_ptr: u32, pdf_len: u32) -> i32, finalize_merger(merger_ptr: u32) -> u32, and get_result_len(result_ptr: u32) -> u32.


// src/pdf-bridge.ts

interface WasmPDFEngine {
  memory: WebAssembly.Memory;
  wasm_alloc: (size: number) => number;
  wasm_free: (ptr: number) => void;
  create_merger: () => number;
  add_pdf: (mergerPtr: number, pdfPtr: number, pdfLen: number) => number;
  finalize_merger: (mergerPtr: number) => number;
  get_result_ptr: (resultHandle: number) => number;
  get_result_len: (resultHandle: number) => number;
  free_result: (resultHandle: number) => void;
  destroy_merger: (mergerPtr: number) => void;
}

let wasmEngine: WasmPDFEngine | null = null;

/**
 * Loads and initializes the WASM PDF engine module.
 * This is called once per Worker and the module is cached for subsequent operations.
 */
async function initWasmEngine(): Promise<WasmPDFEngine> {
  if (wasmEngine) return wasmEngine;

  // Fetch the WASM binary from the CDN/public directory
  const wasmResponse = await fetch('/wasm/pdf-engine.wasm');
  const wasmBytes = await wasmResponse.arrayBuffer();

  // Instantiate the WASM module
  const wasmModule = await WebAssembly.instantiate(wasmBytes, {
    // Import object — provide any host functions the WASM module needs
    env: {
      // Math functions that Emscripten-compiled C may require
      emscripten_resize_heap: () => false,
      __assert_fail: () => { throw new Error('WASM assertion failed'); },
    },
    wasi_snapshot_preview1: {
      // Minimal WASI implementation for Rust-compiled modules
      proc_exit: (code: number) => { throw new Error(`WASM proc_exit: ${code}`); },
      fd_write: () => 0,
      fd_seek: () => 0,
      fd_close: () => 0,
    },
  });

  wasmEngine = wasmModule.instance.exports as unknown as WasmPDFEngine;
  console.log('[WASM Bridge] Engine initialized. WASM memory pages:', wasmEngine.memory.buffer.byteLength / 65536);

  return wasmEngine;
}

/**
 * The core WASM memory bridge function.
 * Copies a Uint8Array into the WASM linear memory heap and returns the pointer.
 */
function copyBytesToWasm(engine: WasmPDFEngine, data: Uint8Array): { ptr: number; len: number } {
  const len = data.byteLength;

  // Allocate memory inside the WASM heap
  // The allocator returns a 32-bit integer pointer (byte offset in linear memory)
  const ptr = engine.wasm_alloc(len);
  if (ptr === 0) {
    throw new Error(`WASM heap allocation failed for ${len} bytes. Out of memory.`);
  }

  // Get a view of the WASM module's linear memory
  // This must be re-fetched after any allocation, as allocations may resize the memory buffer
  const wasmMemoryView = new Uint8Array(engine.memory.buffer);

  // Copy the PDF bytes into WASM memory starting at the allocated pointer
  wasmMemoryView.set(data, ptr);

  return { ptr, len };
}

/**
 * Merges multiple PDF Uint8Arrays using the WASM engine.
 * Manages all WASM heap allocations and deallocations within the function scope.
 */
export async function mergeWithWasm(pdfFiles: Uint8Array[]): Promise<Uint8Array> {
  const engine = await initWasmEngine();
  const allocatedPtrs: number[] = [];
  let mergerPtr = 0;
  let resultHandle = 0;

  try {
    // Create a merger context inside the WASM engine
    mergerPtr = engine.create_merger();
    if (mergerPtr === 0) throw new Error('Failed to create WASM merger context');

    // Add each PDF to the merger
    for (let i = 0; i < pdfFiles.length; i++) {
      const { ptr, len } = copyBytesToWasm(engine, pdfFiles[i]);
      allocatedPtrs.push(ptr);

      const addResult = engine.add_pdf(mergerPtr, ptr, len);
      if (addResult !== 0) {
        throw new Error(`WASM engine rejected PDF at index ${i}. Error code: ${addResult}`);
      }

      console.log(`[WASM Bridge] Added PDF ${i + 1}/${pdfFiles.length} (${Math.round(len / 1024)} KB) at ptr ${ptr}`);

      // Free the source PDF memory immediately after adding to the merger
      // This prevents unbounded memory growth when processing many large files
      engine.wasm_free(ptr);
      allocatedPtrs.pop();
    }

    // Execute the merge and get a handle to the result
    resultHandle = engine.finalize_merger(mergerPtr);
    if (resultHandle === 0) throw new Error('WASM merge finalization failed');

    // Read the result bytes from WASM memory back into JavaScript
    const resultPtr = engine.get_result_ptr(resultHandle);
    const resultLen = engine.get_result_len(resultHandle);

    // Create a JavaScript copy of the result bytes
    // We use slice() to create a new ArrayBuffer, preventing the WASM memory
    // view from becoming invalid after we free the result handle
    const wasmMemoryView = new Uint8Array(engine.memory.buffer);
    const mergedBytes = wasmMemoryView.slice(resultPtr, resultPtr + resultLen);

    console.log(`[WASM Bridge] Merge complete. Output: ${Math.round(resultLen / 1024)} KB`);

    return mergedBytes;

  } finally {
    // Critical: free all allocated WASM memory in the finally block
    // This runs even if an error is thrown, preventing memory leaks
    for (const ptr of allocatedPtrs) {
      engine.wasm_free(ptr);
    }
    if (resultHandle !== 0) {
      engine.free_result(resultHandle);
    }
    if (mergerPtr !== 0) {
      engine.destroy_merger(mergerPtr);
    }
    console.log('[WASM Bridge] All heap allocations freed.');
  }
}
    

Pro Tip: After calling copyBytesToWasm, always free the source pointer as soon as the WASM engine has consumed it. The add_pdf function should internally copy the bytes it needs into its own merger context. Holding source pointers alive for the entire duration of the pipeline is the most common cause of WASM out-of-memory errors, especially when merging 10+ large files. Structure your allocation lifecycle as: allocate → write → call engine function → free immediately.

7. Building the UI Layer: Drag-and-Drop with Reordering

A pipeline is only as useful as its interface. For a KYC document assembler, users need to see which documents are queued, verify the order, and understand the merge status. Here is a minimal but production-ready UI handler:


// src/main.ts (UI layer)

interface QueuedDocument {
  id: string;
  file: PipelineFile;
  status: 'queued' | 'processing' | 'done' | 'error';
}

const documentQueue: QueuedDocument[] = [];
let isMerging = false;

function addFilesToQueue(files: PipelineFile[]): void {
  for (const file of files) {
    documentQueue.push({
      id: crypto.randomUUID(), // Browser-native UUID, no library required
      file,
      status: 'queued',
    });
  }
  renderQueue();
}

function renderQueue(): void {
  const queueEl = document.getElementById('document-queue');
  if (!queueEl) return;

  queueEl.innerHTML = '';

  for (const doc of documentQueue) {
    const item = document.createElement('div');
    item.className = 'queue-item flex items-center gap-3 p-3 bg-zinc-800 rounded-lg mb-2';
    item.dataset.id = doc.id;

    const icon = doc.status === 'done' ? '✅' : doc.status === 'error' ? '❌' : '📄';
    const sizeText = `${doc.file.sizeKB} KB`;

    item.innerHTML = `
      <span class="text-lg">${icon}</span>
      <span class="text-white font-medium flex-1 truncate">${doc.file.name}</span>
      <span class="text-zinc-400 text-sm">${sizeText}</span>
      <button class="remove-btn text-zinc-500 hover:text-rose-400" data-id="${doc.id}">✕</button>
    `;

    queueEl.appendChild(item);
  }
}

async function startMerge(): Promise<void> {
  if (documentQueue.length < 2) {
    alert('Please add at least 2 PDF files to merge.');
    return;
  }
  if (isMerging) return;

  isMerging = true;
  updateMergeButton(true);

  try {
    const files = documentQueue.map((doc) => doc.file);
    await mergeFiles(files);
    // Success: download is triggered inside mergeFiles()
    updateStatus('Merge complete! Your download has started.');
  } catch (error) {
    updateStatus(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
  } finally {
    isMerging = false;
    updateMergeButton(false);
  }
}

function updateMergeButton(isProcessing: boolean): void {
  const btn = document.getElementById('merge-btn') as HTMLButtonElement | null;
  if (!btn) return;
  btn.disabled = isProcessing;
  btn.textContent = isProcessing ? 'Assembling…' : 'Merge PDFs';
}

function updateStatus(message: string): void {
  const statusEl = document.getElementById('status-message');
  if (statusEl) statusEl.textContent = message;
}
    

Managing the Lifecycle of WASM Linear Memory Allocation

Interfacing JavaScript with compiled WebAssembly is fundamentally different from standard web development because WebAssembly has no direct access to the browser's garbage-collected heap. WebAssembly operates on a sandboxed linear memory space — essentially a large, raw ArrayBuffer. When you need to pass a binary file like a PDF from the JavaScript context to the WASM engine, you cannot simply pass a reference to a JavaScript object. Instead, you must allocate a block of memory inside the WASM linear memory heap, write the file's raw bytes into that block, and pass the memory offset (a pointer) to the WASM function.

This memory model requires explicit allocation and deallocation. If you allocate memory inside the WASM heap using exports.malloc(len) and fail to call exports.free(ptr) when the task completes, that memory block remains occupied indefinitely. Over multiple document merge operations, these unfreed allocations accumulate, creating a memory leak that will eventually cause the browser tab to crash with an Out-of-Memory (OOM) error. In our reference architecture, we manage this lifecycle using a strict try...finally block. Even if the WASM merge function throws a runtime exception due to a corrupt PDF file, the code in the finally block executes, guaranteeing that every allocated pointer is freed. This pattern is essential for build stability, particularly on low-end mobile devices where available browser memory is extremely limited.

Why Zero-Copy Data Transfer Matters: Understanding Transferable Objects

When you send data from the main browser thread to a Web Worker using the standard postMessage API, the browser's default behavior is to perform a structured clone of the message payload. Under the hood, this duplicates the data buffer in memory: the main thread keeps its copy, and a new copy is created for the Worker. When merging large document packages — such as a collection of 50 scanned PDFs totaling 100MB — this cloning behavior is highly inefficient. It momentarily consumes an additional 100MB of RAM, triggers garbage collection sweeps, and freezes the UI thread for several hundred milliseconds while copying bytes.

To prevent this overhead, our pipeline uses the Transferable Objects protocol. When posting messages to the Web Worker, we pass the underlying ArrayBuffer of each file's Uint8Array in the second argument of postMessage (the transfer list). This is a zero-copy operation: instead of cloning the data, the browser transfers ownership of the memory block directly to the Worker thread. The main thread loses access to the buffer (it becomes detached and its byte length drops to zero), and the Worker gains immediate access to the raw bytes without any copying delay. This ensures that memory consumption remains flat, allowing the pipeline to scale to massive document batches without risking tab crashes.

Error Handling, Decryption, and Recovery in the Web Worker

A production-grade pipeline must be resilient to real-world document issues, such as corrupt files, invalid PDF structures, and password-protected encryption. If the WASM engine encounters a file with a corrupted cross-reference table, a naive execution will trigger a WebAssembly runtime panic, which halts the Worker thread entirely. To prevent this, our Worker is designed as a state machine that isolates each file's parsing phase. The engine attempts to open each document within a sandboxed try-catch scope. If a parsing failure occurs, the Worker catches the exception, logs the details, and returns a structured error message to the main thread (including the name of the failing file) rather than crashing the thread. For encrypted PDFs, the worker sends a message back to the main thread requesting the document password, unlocking the file locally in memory once the user enters it, without ever sending the password over the network.

8. The Flight Mode Verification: Proving Local Execution

Any developer building a local-first PDF pipeline should be able to prove to their users — and their security auditors — that the pipeline genuinely does not transmit documents. The most intuitive proof is the Flight Mode Verification.

The Flight Mode Verification

1. Open MojoDocs. 2. Turn off WiFi/Internet. 3. Process the file. 4. It completes instantly without any data leaving your device.

For developers building their own pipeline, the equivalent developer audit is the Network Tab Test: open Chrome DevTools → Network panel → select your PDF files → trigger the merge → observe that zero outbound XHR, fetch, or WebSocket requests are fired. The only requests visible will be for static assets (HTML, JS, WASM) which were already loaded when the page first opened.

You can also write an automated test for this property using the service worker API. Intercept all fetch events in a test service worker, log them, and assert that no PDF binary data is present in any outbound request body. This is the kind of privacy assertion that security teams at regulated fintech companies require before approving a client-side document tool for use in their KYC flows.

9. Indian Fintech and KYC Applications: Real-World Implementation Patterns

The theoretical pipeline described above maps directly to one of the most common document workflows in India's digital economy: the KYC document bundle assembly.

Consider a lending platform — a digital NBFC or a Buy-Now-Pay-Later service — that needs to collect a verified KYC bundle from each loan applicant. The standard bundle includes:

  1. Aadhaar Card (front and back): Downloaded from the UIDAI mAadhaar app or DigiLocker as a secured PDF. Applicants are now advised to use Masked Aadhaar (where the first 8 digits of the 12-digit number are replaced with asterisks) to reduce biometric identifier exposure.
  2. PAN Card: Either a physical scan or the e-PAN downloaded from the NSDL/UTIITSL portals as a password-protected PDF.
  3. 3 Months Bank Statement: Downloaded from the bank's netbanking portal, typically a 10-20 page PDF with transaction history, balance summaries, and account metadata.
  4. Income Proof (Form 16 or recent salary slips): Employer-issued PDFs, often with watermarks and digital signatures.
  5. Address Proof (Electricity Bill, Rental Agreement, or Voter ID): Scanned PDFs or government-issued electronic documents.

Traditionally, users would download all these files from various portals (UIDAI, NSDL, their bank's app, their employer's HR portal), upload them all to the lending platform's server, and the platform's backend would merge them using a service like AWS Textract, Adobe PDF Services, or a custom Ghostscript pipeline running on EC2 instances. Every one of these documents would transit the internet and sit on the platform's servers during processing.

With a client-side WASM pipeline, the flow changes completely:

  1. The applicant downloads all required documents to their local device from their respective portals.
  2. They open the lending platform's KYC portal (which embeds the client-side PDF pipeline).
  3. They drag-and-drop all documents into the queue interface.
  4. The WASM engine merges the documents in the correct legal order, entirely in the browser's RAM.
  5. The merged PDF is displayed in a local preview pane for the applicant to verify.
  6. Only after the applicant clicks Submit KYC Bundle does the merged PDF get uploaded — as a single, verified submission — to the platform's encrypted document storage.

This is a massive improvement. Instead of the platform receiving 5-7 individual sensitive files (each requiring individual storage, individual access logs, individual audit trails), it receives a single merged document submitted explicitly by the user. The processing window where sensitive data was accessible in cloud memory is eliminated entirely.

For Blinkit print stores, Zepto quick-commerce partners, and Swiggy Instamart agents who help customers print and submit government documents at local counters, a client-side merge tool is even more critical: operators at these counters should never need to upload customer documents to external services. Running the merge locally means the customer's documents are processed on the device in the operator's hand, and no trace of the document is left on any cloud server.

10. Performance Optimization for Mobile and Low-End Devices

India has one of the world's highest concentrations of sub-₹15,000 Android smartphones. Your PDF pipeline must perform acceptably on a 4-year-old Redmi or a Samsung Galaxy M-series with 3GB of RAM. Here are the specific optimizations that matter most for this context:

Chunk Processing for Large Scanned Files

A scanned government document bundle (Aadhaar + PAN + bank statement + Form 16) typically totals 15-60MB. Loading all files simultaneously into WASM memory on a device with 3GB RAM (of which only ~1.5GB is actually available to apps after OS overhead) can trigger out-of-memory crashes. The solution is sequential processing with immediate deallocation:


// Process PDFs sequentially rather than loading all at once
// This keeps peak WASM memory usage low, critical for mobile devices

async function assembleSequentially(files: Uint8Array[]): Promise<Uint8Array> {
  const engine = await initWasmEngine();
  const mergerPtr = engine.create_merger();

  for (let i = 0; i < files.length; i++) {
    // Load ONE file at a time
    const { ptr, len } = copyBytesToWasm(engine, files[i]);

    // Add to merger (internally copies into merger's own memory)
    engine.add_pdf(mergerPtr, ptr, len);

    // Free source bytes immediately — don't hold all files in WASM RAM simultaneously
    engine.wasm_free(ptr);

    // Give the browser a chance to run GC and handle UI events
    // This prevents "page unresponsive" warnings on very slow devices
    await new Promise((resolve) => setTimeout(resolve, 0));
  }

  const resultHandle = engine.finalize_merger(mergerPtr);
  const resultPtr = engine.get_result_ptr(resultHandle);
  const resultLen = engine.get_result_len(resultHandle);
  const mergedBytes = new Uint8Array(engine.memory.buffer).slice(resultPtr, resultPtr + resultLen);

  engine.free_result(resultHandle);
  engine.destroy_merger(mergerPtr);

  return mergedBytes;
}
    

Progress Reporting from the Worker


// In the Web Worker, post progress updates after each file is processed
// The main thread updates the UI progress bar without blocking computation

for (let i = 0; i < files.length; i++) {
  // ... process file ...
  
  // Report progress back to the main thread
  self.postMessage({
    type: 'progress',
    current: i + 1,
    total: files.length,
    percentComplete: Math.round(((i + 1) / files.length) * 100),
  });
}
    

11. TypeScript Type Safety for the Pipeline

A production pipeline should be fully typed. Here are the core type definitions that make the pipeline maintainable and refactor-safe:


// src/types.ts

export type MergeStatus = 'idle' | 'reading' | 'processing' | 'finalizing' | 'done' | 'error';

export interface PDFPipelineConfig {
  /** Maximum total input size in bytes before warning the user */
  maxTotalSizeBytes: number;
  /** Maximum number of input files */
  maxFileCount: number;
  /** Output filename for the merged document */
  outputFilename: string;
  /** Whether to use the WASM engine (true) or pdf-lib fallback (false) */
  useWasmEngine: boolean;
}

export type WorkerInboundMessage =
  | { type: 'merge'; files: Array<{ name: string; bytes: ArrayBuffer }> }
  | { type: 'cancel' };

export type WorkerOutboundMessage =
  | { type: 'progress'; current: number; total: number; percentComplete: number }
  | { type: 'success'; result: ArrayBuffer }
  | { type: 'error'; message: string; code: number };

export interface PDFPipelineResult {
  mergedBytes: Uint8Array;
  pageCount: number;
  processingTimeMs: number;
  inputFileSizesKB: number[];
  outputFileSizeKB: number;
}
    

12. Deployment and CDN Strategy for the WASM Binary

The WASM binary must be served with the correct HTTP headers for optimal performance and security. Two headers are mandatory when using SharedArrayBuffer (required for cross-thread memory sharing):


# nginx.conf — headers required for WASM + SharedArrayBuffer
location / {
    add_header Cross-Origin-Opener-Policy  "same-origin";
    add_header Cross-Origin-Embedder-Policy "require-corp";
    add_header Cross-Origin-Resource-Policy "same-origin";
}

# Set proper MIME type for WASM files for faster loading
location ~* \.wasm$ {
    add_header Content-Type "application/wasm";
    # Cache WASM binary aggressively — version it via filename hash
    add_header Cache-Control "public, max-age=31536000, immutable";
}
    

For CDN deployment (Cloudflare, Vercel Edge, or AWS CloudFront), the WASM binary should be fingerprinted with a content hash in its filename (e.g., pdf-engine.a8f3c2d1.wasm) and cached with immutable cache headers. After the first page load, users have the engine cached locally in their browser and will experience instant processing on every subsequent visit — with no network request required for the engine itself.

Pro Tip: Preload the WASM binary using a <link rel="preload" as="fetch" crossorigin href="/wasm/pdf-engine.wasm"> tag in your HTML head. This instructs the browser to begin fetching the WASM binary in parallel with parsing the rest of your HTML, reducing the time-to-interactive for the pipeline by 30-60% on first page load. Combine with a Service Worker that caches the WASM binary on install for a truly offline-capable experience.

13. Comparison with Alternative Approaches

Let's situate the client-side WASM pipeline against the most common alternative architectures a developer might consider:

Architecture Latency Monthly Cost (50K users) Data Privacy Offline Support
Managed Cloud PDF API 5-30 seconds (upload + queue + process) ₹4.5L – ₹12L None — files on 3rd-party servers No
Self-Hosted EC2 Worker 8-60 seconds (upload latency dominant) ₹35K – ₹85K Medium — transit risk, your S3 No
Serverless Lambda (pdf-lib) 6-45 seconds (cold start + upload) ₹2K – ₹7K Medium — transit risk No
Client-Side WASM Pipeline 0.5-5 seconds (local CPU only) ₹500 – ₹2K (CDN only) Maximum — never leaves device Yes (after first load)

14. Security Hardening: What the Browser Sandbox Guarantees (and What It Doesn't)

The WASM sandbox provides strong guarantees: the module can only access memory within its own linear memory space, it cannot make system calls, and it cannot access the file system directly. However, developers should be aware of several edge cases:

  • Third-party scripts on the same page: If your PDF pipeline page includes analytics scripts, social media widgets, or ad tags from third-party origins, those scripts run in the same JavaScript context and can potentially read variables from your pipeline (including file names or status updates). Isolate your pipeline on a subdomain or in an iframe with strict CSP headers to prevent cross-script data leakage.
  • Browser extensions: Malicious browser extensions with broad host permissions can inject scripts into any tab and read DOM content, localStorage, or even intercept fetch calls. There is no server-side defence against a compromised browser. Educate users about using trusted browser configurations for sensitive document operations.
  • Console logging: Remove all console.log statements that output file names or byte lengths in production builds. Attackers with DevTools access can read console output. Use a build-time flag to strip logs automatically.

15. Try the Production Pipeline at MojoDocs

Everything described in this article is deployed in production at MojoDocs PDF Merger. You can use it right now to merge your KYC documents, legal bundles, or any collection of PDF files — with the verifiable guarantee that no file leaves your browser. The pipeline handles files up to several hundred megabytes, supports page reordering, and works across all modern browsers on desktop and mobile.

The underlying WASM engine architecture — including the Rust compilation pipeline, the wasm-bindgen type bridge, and the SharedArrayBuffer memory strategy — is detailed in our companion engineering guide: The Engineering Behind MojoDocs WebAssembly. That article covers how we compile production Rust code to WASM, manage memory safety guarantees, and ensure the engine is stable under all edge cases including malformed or encrypted input PDFs.

Client-side PDF assembly is not a prototype technology or a research curiosity. It is a production-ready, battle-tested pattern that eliminates server infrastructure costs, eliminates data sovereignty risks, and delivers faster processing than cloud alternatives on any modern device. For fintech companies, legal platforms, government-adjacent services, and individual developers building document workflows for Indian users, this is the architecture that respects both privacy and the rupee.

js assembly pdf local merge pdf programmatically client side wasm pdf library javascript pdf pipeline webassembly pdf merge client side pdf local first web apps pdf merger data sovereignty fintech kyc pipeline
Share article
WebAssembly
Client-Side Engine
Zero Latency
Processing Speed
0.00 KB
Data Retention
AES-256
Security Standard