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
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);
});
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' })
);
}
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 });
}
};
// 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));
};
});
}
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:
- Rendering each PDF page to an offscreen canvas
- Re-encoding the canvas pixels using WebAssembly-compiled image encoders
- 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();
}
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);
}
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;
}
}
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);
}
}
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' },
],
},
];
},
};
Production Implementation at KAOpdf
This architecture is currently running live in production across more than 35 web-based tools on KAOpdf:
- Compress PDF — MozJPEG WebAssembly quantization
- Merge PDF — In-memory page aggregation
- Split PDF — Client-side page extraction
- OCR PDF — Offline Tesseract.js WASM character recognition
- AI PDF Summarizer — On-device WebGPU model execution
Across all tools, the fundamental contract remains unchanged: zero file uploads, zero remote storage, and complete user privacy.
Engineering Takeaways
-
pdf-lib for structure, PDF.js for rendering:
pdf-libhandles fast document generation, whilePDF.jsis the most resilient parser for non-standard PDFs. - Transferables are mandatory: Always pass ArrayBuffers in worker transfer lists to prevent costly memory duplication.
- WebAssembly image codecs deliver real gains: WASM-compiled MozJPEG consistently beats native browser canvas encoders in compression efficiency.
-
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)