Introduction: The Architectural Flaw of Server-Side Document Processing

For over two decades, web utilities that manipulate Portable Document Format (PDF) files have followed a rigid client-server architecture: users upload their confidential documents to a central cloud server, backend binaries (such as pdftk, Ghostscript, or PyPDF) perform the requested modifications, and the resulting file is returned via a temporary download URL.

While conceptually straightforward, this architecture introduces major liabilities:

With the maturation of modern JavaScript engines, Web Workers, and typed binary representations (ArrayBuffer and Uint8Array), it is now possible to execute 100% of PDF parsing, rendering, and serialization entirely within the client's web browser.

Core Technologies for Client-Side PDF Processing

To build an industrial-grade client-side PDF tool like MassForge PDF, two specialized libraries must work in harmony:

Engine / Tool Primary Responsibility Key APIs Utilized
pdf-lib Binary creation, merging, splitting, watermarking, encryption, and object tree serialization. PDFDocument.load(), copyPages(), embedFont(), save()
pdf.js Vector rasterization, text layer extraction, thumbnail previews, and Canvas rendering. getDocument(), page.render(), getTextContent()
Web Workers Offloading heavy byte operations and decompression from the UI thread. postMessage(), Transferable Objects

Step-by-Step Implementation: Document Merging

Let us examine how to merge multiple PDF files without decompressing pages into raster images (which would destroy text searchability and bloat file sizes). pdf-lib directly manipulates the PDF cross-reference (XRef) table and page dictionary objects:

pdf_merger.js JavaScript
import { PDFDocument } from 'pdf-lib';

/**
 * Merges multiple ArrayBuffers into a single unified PDF Blob.
 * @param {ArrayBuffer[]} fileBuffers - Array of raw PDF byte buffers.
 * @param {Function} progressCallback - Progress reporting hook.
 * @returns {Promise<Blob>} The final merged PDF Blob ready for download.
 */
export async function mergePDFs(fileBuffers, progressCallback) {
    // 1. Create a pristine output PDFDocument
    const outputDoc = await PDFDocument.create();
    let pagesCopied = 0;
    
    for (let index = 0; index < fileBuffers.length; index++) {
        // 2. Load the source PDF into memory
        const sourceDoc = await PDFDocument.load(fileBuffers[index], {
            ignoreEncryption: false
        });
        
        // 3. Extract all page indices from the source
        const pageCount = sourceDoc.getPageCount();
        const pageIndices = Array.from({ length: pageCount }, (_, i) => i);
        
        // 4. Copy vector page objects to the destination document
        const copiedPages = await outputDoc.copyPages(sourceDoc, pageIndices);
        
        for (const page of copiedPages) {
            outputDoc.addPage(page);
            pagesCopied++;
            if (progressCallback) {
                progressCallback(pagesCopied);
            }
        }
    }
    
    // 5. Serialize dictionary objects and compress streams
    const outputBytes = await outputDoc.save({ useObjectStreams: true });
    
    // 6. Return as client-side Blob with application/pdf MIME type
    return new Blob([outputBytes], { type: 'application/pdf' });
}

Offloading Rendering to Web Workers via Transferable Objects

When rendering preview thumbnails for a 100-page document, executing pdf.js on the main thread causes severe UI jank. To achieve a smooth 60 FPS user experience, page rendering must be delegated to background Web Workers.

When dispatching large buffers to workers, standard structured cloning duplicates memory. By passing the buffer as a Transferable Object, ownership is transferred with zero copy overhead:

main_thread.js JavaScript
const worker = new Worker('pdf_worker.js');

function processLargeDocument(fileArrayBuffer) {
    // Transfer ownership of fileArrayBuffer directly to the worker
    // The main thread buffer becomes detached (byteLength = 0) instantly
    worker.postMessage(
        { action: 'GENERATE_THUMBNAILS', buffer: fileArrayBuffer },
        [fileArrayBuffer]
    );
}

worker.onmessage = (event) => {
    const { thumbnails, totalPages } = event.data;
    renderThumbnailGrid(thumbnails);
};

Memory Management & Browser Sandbox Safeguards

JavaScript runs inside an automated garbage-collected runtime. However, large binary buffers (such as multiple 100 MB PDFs loaded simultaneously) can easily exceed browser tab memory allocations. To ensure application stability:

  1. Explicit Dereferencing: Always set loaded PDFDocument instances, Uint8Array buffers, and intermediate Canvas elements to null as soon as processing completes.
  2. Revoking Object URLs: Whenever a temporary preview image or download link is generated via URL.createObjectURL(blob), systematically call URL.revokeObjectURL(url) when the component unmounts or updates.
  3. Incremental Batching: When rendering document thumbnails, process pages in batches of 5 to 10 rather than firing 100 simultaneous canvas rasterizations.
💡 Performance Benchmark Summary

In empirical testing across modern browsers (Chrome 120, Safari 17, Firefox 122), merging ten 5 MB PDF files client-side took an average of 820 milliseconds, compared to 12.4 seconds on traditional server-side platforms that require uploading and downloading over typical 4G/5G connections.

Conclusion

Client-side document processing represents the future of secure, responsive web applications. By eliminating server roundtrips, web developers can guarantee absolute user privacy, slash infrastructure hosting bills to zero, and provide instantaneous interactions that delight end users.

AS

Ataberk Susam

Software Developer & Engineering Student

METU Mechanical Engineering student building client-side architectures, computer vision algorithms, and desktop software. Creator of MassForge PDF.