DEV Community

Cover image for How I Built a Privacy-First PDF Processor That Never Uploads Your Files
KAOpdf
KAOpdf

Posted on Originally published at kaopdf.com

How I Built a Privacy-First PDF Processor That Never Uploads Your Files

TL;DR: Most online PDF tools transmit your documents to remote infrastructure. We engineered KAOpdf to execute all PDF manipulations directly inside the browser sandbox using WebAssembly and Web Workers — zero server uploads, zero remote storage, zero data leakage risk. Here is the technical breakdown of how we built it.


The Problem With "Free" Online PDF Converters

If you have ever used a standard online PDF compressor, converter, or merger, you have almost certainly uploaded your documents to a third-party server. In many cases, that is an unacceptable risk. Typical PDF payloads contain sensitive data:

  • Executed contracts and legal agreements
  • Patient medical records and diagnoses
  • Banking, tax, and financial statements
  • Proprietary internal company documentation

When uploaded, that data travels across external networks, lands on third-party file systems (often indefinitely), and is processed by infrastructure you cannot audit.

We set out to build a different model: a document processing suite where files never leave the user device. Here is the technical stack and architecture that makes client-side PDF execution possible.


Architecture Overview

┌─────────────────────────────────────────────┐
│                  Browser                    │
│                                             │
│  ┌──────────┐    ┌──────────────────────┐  │
│  │ File API │───▶│   WebAssembly Module  │  │
│  └──────────┘    │  (pdf-lib / PDF.js /  │  │
│                  │   custom WASM binary) │  │
│  ┌──────────┐    └──────────┬───────────┘  │
│  │  Canvas  │◀──────────────┘              │
│  │  Blob    │                              │
│  │  URL     │                              │
│  └──────────┘                              │
└─────────────────────────────────────────────┘
         ↕  Zero network requests for file data
Enter fullscreen mode Exit fullscreen mode

All heavy tasks execute inside dedicated Web Workers to ensure the main UI thread stays at a fluid 60 FPS. Once processing finishes, the output is converted into an in-memory Blob URL and handed directly to the user for immediate download. Not a single byte of file content is sent over the wire.


Core Stack

Layer Technology
PDF parsing & manipulation pdf-lib + PDF.js
Computation offloading Dedicated Web Workers
Image compression (WASM) @jsquash/mozjpeg + @jsquash/webp
Application framework Next.js (App Router)
State management React useReducer + useRef

Step 1: Reading PDFs with the HTML5 File API

The first milestone is reading document bytes into local browser memory without triggering network activity:

// Local memory read — zero network interaction
async function readPDFFile(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (e) => resolve(e.target.result); // ArrayBuffer
    reader.onerror = reject;
    reader.readAsArrayBuffer(file);
  });
}

// Event handler
const input = document.getElementById('file-input');
input.addEventListener('change', async (e) => {
  const file = e.target.files[0];
  const buffer = await readPDFFile(file);

  // buffer is an ArrayBuffer allocated strictly in local client memory
  processPDF(buffer);
});
Enter fullscreen mode Exit fullscreen mode

Using FileReader.readAsArrayBuffer() provides raw byte access inside JavaScript memory. From this point forward, every operation operates strictly as local computation.


Step 2: Document Manipulation via pdf-lib

pdf-lib is a standalone JavaScript library that runs natively in the browser without any Node.js runtime or server dependencies. Here is how document merging is implemented client-side:

import { PDFDocument } from 'pdf-lib';

async function mergePDFs(arrayBuffers) {
  // Initialize a new empty PDF document
  const mergedPdf = await PDFDocument.create();

  for (const buffer of arrayBuffers) {
    // Load each PDF from ArrayBuffer in memory
    const sourcePdf = await PDFDocument.load(buffer);

    // Copy all pages into the merged target document
    const pages = await mergedPdf.copyPages(
      sourcePdf, 
      sourcePdf.getPageIndices()
    );

    pages.forEach((page) => mergedPdf.addPage(page));
  }

  // Serialize to Uint8Array within client memory
  const mergedBytes = await mergedPdf.save();

  // Create an instant download URL without remote servers
  return URL.createObjectURL(
    new Blob([mergedBytes], { type: 'application/pdf' })
  );
}
Enter fullscreen mode Exit fullscreen mode

This entire merge cycle runs synchronously in memory without a single outbound network packet.


Step 3: Offloading Computation to Web Workers

PDF serialization and parsing can easily freeze the main JavaScript thread on documents with hundreds of pages. We isolate all computation inside a background Web Worker:

// worker.js
import { PDFDocument } from 'pdf-lib';

self.onmessage = async ({ data }) => {
  const { type, payload } = data;

  try {
    switch (type) {
      case 'COMPRESS': {
        const result = await compressPDF(payload.buffer, payload.quality);
        // Transfer the ArrayBuffer back (zero-copy memory transfer)
        self.postMessage({ success: true, buffer: result }, [result]);
        break;
      }
      case 'MERGE': {
        const result = await mergePDFs(payload.buffers);
        self.postMessage({ success: true, buffer: result }, [result]);
        break;
      }
    }
  } catch (err) {
    self.postMessage({ success: false, error: err.message });
  }
};
Enter fullscreen mode Exit fullscreen mode
// main thread
const worker = new Worker(new URL('./worker.js', import.meta.url));

function processPDFInWorker(buffer) {
  return new Promise((resolve, reject) => {
    worker.postMessage(
      { type: 'COMPRESS', payload: { buffer, quality: 0.8 } },
      [buffer] // Transferable object: passes pointer without memory duplication
    );

    worker.onmessage = ({ data }) => {
      if (data.success) resolve(data.buffer);
      else reject(new Error(data.error));
    };
  });
}
Enter fullscreen mode Exit fullscreen mode

By specifying [buffer] in the transfer list, ownership of the memory block is transferred instantly to the worker thread without invoking expensive structured clone copying.


Step 4: PDF Compression with WebAssembly Codecs

File sizes in scanned or multi-page documents are predominantly caused by embedded raster images. Effective compression requires:

  1. Rendering each PDF page to an offscreen canvas
  2. Re-encoding the canvas pixels using WebAssembly-compiled image encoders
  3. Re-assembling the PDF with re-quantized image streams
import { getDocument } from 'pdfjs-dist';
import * as mozjpeg from '@jsquash/mozjpeg';

async function compressPDFImages(buffer, quality = 0.75) {
  const pdfDoc = await getDocument({ data: buffer }).promise;
  const newPdf = await PDFDocument.create();

  for (let i = 1; i <= pdfDoc.numPages; i++) {
    const page = await pdfDoc.getPage(i);
    const viewport = page.getViewport({ scale: 1.5 }); // Target 144 DPI

    // Render to OffscreenCanvas without needing DOM bindings
    const canvas = new OffscreenCanvas(
      viewport.width, 
      viewport.height
    );
    const ctx = canvas.getContext('2d');

    await page.render({ canvasContext: ctx, viewport }).promise;

    // Extract raw image data
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    // Encode via MozJPEG WebAssembly
    const compressedBytes = await mozjpeg.encode(imageData, { quality: quality * 100 });

    // Embed back into destination document
    const jpgImage = await newPdf.embedJpg(compressedBytes);
    const newPage = newPdf.addPage([viewport.width, viewport.height]);
    newPage.drawImage(jpgImage, {
      x: 0, y: 0,
      width: viewport.width,
      height: viewport.height,
    });
  }

  return await newPdf.save();
}
Enter fullscreen mode Exit fullscreen mode

Why MozJPEG over Native Canvas Encoders?

Browser native canvas toBlob() implementations prioritize encoding speed over file size compression. MozJPEG compiled to WebAssembly consistently produces 30% to 50% smaller image files at equivalent perceptual quality metrics (SSIM and PSNR). The gzipped WASM module weighs only around 200 KB and runs near native execution speeds.


Performance Benchmarks

Benchmark performed on a 10-page scanned color document (original payload: 8.2 MB):

Processing Method Output Size Visual Fidelity Duration
Native canvas.toBlob() (quality 0.8) 4.1 MB Good 1.2s
MozJPEG WASM (quality 75) 2.3 MB Sharp 1.8s
MozJPEG WASM (quality 60) 1.4 MB Acceptable 1.8s
Server-side Ghostscript engine 1.1 MB Good ~3s + upload transit

The client-side WASM pipeline approaches within 30% of server-side Ghostscript performance while completely eradicating upload latency and security risks.


Step 5: Memory Management and Streaming

When loading a 50 MB document, naïve implementations can easily inflate memory usage to over 200 MB due to multiple concurrent representations (ArrayBuffer, parsed AST, canvas pixel arrays).

To prevent tab crashes on memory-constrained devices:

// Stream processing: parse and clean page-by-page
async function* streamPages(buffer) {
  const pdfDoc = await getDocument({ data: buffer }).promise;

  for (let i = 1; i <= pdfDoc.numPages; i++) {
    const page = await pdfDoc.getPage(i);
    yield page;

    // Explicitly evict page cache from PDF.js internal store
    page.cleanup();
  }
}

// Bounded heap iteration
for await (const page of streamPages(buffer)) {
  await processPage(page);
}
Enter fullscreen mode Exit fullscreen mode

For files exceeding 100 MB, the application advises splitting the document before applying compression or conversions — a pattern supported directly in KAOpdf Split PDF.


Handling Edge Cases

Password-Protected Documents

async function loadProtectedPDF(buffer, password) {
  try {
    return await PDFDocument.load(buffer, { 
      password,
      ignoreEncryption: false 
    });
  } catch (err) {
    if (err.message.includes('password')) {
      throw new Error('Incorrect password entered.');
    }
    if (err.message.includes('encrypted')) {
      throw new Error('Unsupported encryption standard (256-bit AES).');
    }
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Corrupted or Non-Standard PDF Streams

PDF.js is significantly more tolerant of damaged cross-reference tables than strict parsers. When structural modification fails in pdf-lib, we fall back to PDF.js rendering:

async function robustLoad(buffer) {
  try {
    return await PDFDocument.load(buffer);
  } catch {
    // Fallback: render damaged pages via PDF.js and reconstruct
    const pdfJsDoc = await getDocument({
      data: buffer,
      stopAtErrors: false
    }).promise;
    return rebuildFromRenderedPages(pdfJsDoc);
  }
}
Enter fullscreen mode Exit fullscreen mode

Security Model and HTTP Headers

Moving processing to the client eliminates data breach vectors on the backend, but requires strict client-side browser controls.

Security Guarantees

  • Zero server breach exposure for uploaded files
  • Immunity to man-in-the-middle transmission interception
  • Zero regulatory storage liability (GDPR, HIPAA compliance by design)

Client Configuration Requirements

To enable high-performance multithreaded WASM and SharedArrayBuffer, modern browsers mandate Cross-Origin Isolation headers:

// next.config.js — Cross-Origin Isolation headers
const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
          { key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' },
        ],
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

Production Implementation at KAOpdf

This architecture is currently running live in production across more than 35 web-based tools on KAOpdf:

Across all tools, the fundamental contract remains unchanged: zero file uploads, zero remote storage, and complete user privacy.


Engineering Takeaways

  1. pdf-lib for structure, PDF.js for rendering: pdf-lib handles fast document generation, while PDF.js is the most resilient parser for non-standard PDFs.
  2. Transferables are mandatory: Always pass ArrayBuffers in worker transfer lists to prevent costly memory duplication.
  3. WebAssembly image codecs deliver real gains: WASM-compiled MozJPEG consistently beats native browser canvas encoders in compression efficiency.
  4. Enforce bounded memory loops: Always call .cleanup() after rendering pages to prevent tab crashes on mobile browsers.

Resources and Documentation


If you are experimenting with client-side document processing or running into WebAssembly memory bottlenecks, feel free to share your thoughts or questions in the discussion below.

Top comments (0)