DEV Community

Cover image for How We Built a Zero-Backend, In-Browser PDF Engine to Eliminate Cloud SaaS Bloat
thisran
thisran

Posted on

How We Built a Zero-Backend, In-Browser PDF Engine to Eliminate Cloud SaaS Bloat

As cloud infrastructure bills explode and tech giants pour billions into expanding hyperscaler capacity, modern B2B SaaS has quietly standardized an absurd architectural pattern: charging enterprise seats $20 to $40 per month to execute basic byte manipulations on remote servers.

Consider what actually happens when a user clicks "Rotate Page" or "Merge Two Documents" on a legacy cloud-based PDF platform like Adobe Acrobat Web or DocuSign:

  1. A 40MB contract containing sensitive personal identification information (PII), proprietary financial terms, or executive signatures is serialized into a multi-part form payload.
  2. The payload traverses high-latency consumer broadband, undergoes TLS termination at an API gateway, and lands in an ephemeral Amazon S3 bucket.
  3. An AWS Lambda worker or containerized microservice pulls the payload, spins up a headless C++ engine or Ghostscript subprocess, parses the document tree, flips a single integer in a page object’s dictionary (/Rotate 90), and serializes it back to S3.
  4. The server generates a pre-signed URL, and the client downloads the updated 40MB file over the WAN.

This workflow burns compute cycles, racks up egress costs, introduces compliance vulnerabilities (GDPR, HIPAA, SOC 2), and completely fails the moment a user’s internet drops.

When we architected HandleMyFile, we set out to prove that this client-server round-trip is completely unnecessary. Modern client devices—from M-series MacBooks to budget Android phones—ship with multi-core processors, hardware-accelerated SIMD units, and multi-gigabyte memory pools. By combining WebAssembly (WASM), Web Workers, and the native Web Crypto API, we can execute complex PDF manipulation, document signing, client-side OCR, and lossless compression locally inside the browser sandbox at zero marginal compute cost.

Here is the architectural breakdown of how we built a zero-backend, 100% private in-browser document workstation.


The Underlying Anatomy of a PDF

To understand why remote processing is an antipattern for simple document tasks, you have to look at the PDF specification (ISO 32000-1).

A PDF is essentially an indexed graph of indirect objects terminated by a cross-reference (xref) table or stream. The structural skeleton looks like this:

%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >> endobj
4 0 obj << /Length 55 >> stream
...stream bytes...
endstream endobj
xref
0 5
0000000000 65535 f 
0000000009 00000 n 
...
trailer << /Size 5 /Root 1 0 R >>
startxref
562
%%EOF
Enter fullscreen mode Exit fullscreen mode

When you merge two documents, you do not need to decode and re-encode high-resolution image streams or recalculate font raster matrices. You simply need to:

  1. Parse the indirect object dictionaries across both source buffers.
  2. Re-index the object identifiers to avoid collision in the target namespace.
  3. Update the root /Pages array to point to the newly indexed page objects.
  4. Append an incremental update or rebuild the xref table at the end of the byte stream.

Running this in a remote Python or Ruby container on AWS ECS wastes millions of CPU cycles on network I/O serialization. The browser's native JavaScript engine can parse, mutate, and rebuild these cross-reference structures in less time than it takes to complete a single TLS handshake.


System Architecture: Moving Compute to the Edge of the Edge

To make client-side PDF processing production-viable for 100MB+ documents without freezing the user interface, we designed a zero-backend pipeline decoupled across isolated threads:

┌────────────────────────────────────────────────────────┐
│                   Main Browser Thread                  │
│  - React UI / DOM Updates                              │
│  - Canvas Preview Layer                                │
│  - Drag & Drop Event Handling                          │
└──────────────────────────┬─────────────────────────────┘
                           │ Zero-Copy Transfer
                           │ (Transferable Objects / SharedArrayBuffer)
                           ▼
┌────────────────────────────────────────────────────────┐
│                   Dedicated Web Worker                 │
│  ┌──────────────────┐  ┌─────────────────────────────┐ │
│  │  pdf-lib / WASM  │  │      Tesseract.js WASM      │ │
│  │  Document Parser │  │      Local Optical OCR      │ │
│  └─────────┬────────┘  └──────────────┬──────────────┘ │
│            │                          │                │
│            ▼                          ▼                │
│  ┌───────────────────────────────────────────────────┐ │
│  │           Web Crypto API Sandbox Subsystem        │ │
│  │        (SHA-256 Hashing, Ephemeral Keys)          │ │
│  └───────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

1. Zero-Copy Thread Boundary Offloading

Executing heavy byte manipulation on the browser's main execution context drops frames and degrades the user experience. We isolate all document operations inside long-lived Web Workers.

Instead of passing file payloads via standard structured cloning (which performs an expensive, blocking memory copy), we transfer ownership of the underlying ArrayBuffer directly to the worker context:

// Main Thread dispatching to Worker
const buffer = await file.arrayBuffer();
worker.postMessage({ type: 'PROCESS_DOCUMENT', payload: buffer }, [buffer]);
// `buffer.byteLength` is now 0 on the main thread (zero memory allocation overhead)
Enter fullscreen mode Exit fullscreen mode

2. Byte Manipulation via pdf-lib & PDF.js WASM

For structural edits (splitting, merging, page reordering, vector signing), we leverage compiled byte parsers (pdf-lib and custom WebAssembly glue routines). The engine loads the raw binary array into memory, traverses the object tree, injects user vector signatures or rotation flags, and re-emits a compressed binary blob directly into the client's memory allocation pool.

3. Client-Side OCR via Tesseract.js WASM

Optical Character Recognition has historically been one of the biggest excuses for shipping documents to remote infrastructure. By compiling Tesseract’s native C++ engine to WebAssembly using Emscripten, we load quantized OCR models (under 2MB compressed) directly into client IndexedDB storage. The worker pulls image canvas buffers from PDF rasterizations, runs multi-threaded SIMD inference locally, and outputs selectable text layers without a single byte ever touching a third-party server.


Concrete Implementation: Non-Blocking Local Merge Engine

Here is a practical look at how our dedicated Web Worker implements non-blocking, multi-file merging. Notice the use of ignoreEncryption handling, safe indirect object copying, and direct memory export:

import { PDFDocument } from 'pdf-lib';

/**
 * Merges multiple raw PDF ArrayBuffers completely in-memory
 * without allocating duplicate memory or blocking the main event loop.
 */
export async function mergePDFsLocally(pdfBuffers: ArrayBuffer[]): Promise<Blob> {
  // Initialize the target document context
  const mergedDoc = await PDFDocument.create();

  for (let i = 0; i < pdfBuffers.length; i++) {
    // Load binary stream with resilient error boundaries
    const sourceDoc = await PDFDocument.load(pdfBuffers[i], { 
      ignoreEncryption: true,
      updateMetadata: false 
    });

    // Extract page indices and transfer ownership of indirect objects
    const pageIndices = sourceDoc.getPageIndices();
    const copiedPages = await mergedDoc.copyPages(sourceDoc, pageIndices);

    // Append mutated page objects into the consolidated catalog tree
    for (const page of copiedPages) {
      mergedDoc.addPage(page);
    }
  }

  // Serialize the new cross-reference table and write the EOF marker
  const mergedBytes = await mergedDoc.save({ useObjectStreams: true });

  // Return an immutable binary Blob ready for immediate disk download
  return new Blob([mergedBytes], { type: 'application/pdf' });
}
Enter fullscreen mode Exit fullscreen mode

By setting useObjectStreams: true, the engine packs non-stream objects into compressed object streams, often yielding a merged file size smaller than the sum of its inputs—all within a single-digit millisecond timeframe.


Performance Benchmark: Cloud SaaS vs. In-Browser WASM

To quantify the operational advantages, we benchmarked standard document operations on a mid-tier client machine (MacBook Air M2, 16GB RAM) running Chrome 124 over an average 50 Mbps residential broadband connection.

We compared the traditional client-to-cloud round-trip against the local execution model implemented in HandleMyFile:

Architectural Metric Commercial Cloud SaaS (Adobe / SmallPDF) HandleMyFile Client-Side WASM Performance Delta
Cold Start / Latency (30MB File) 1,840ms (TLS + S3 Upload + Worker Pull) 18ms (Zero Network Transit) 102x faster
Page Rotation Execution ~2,100ms end-to-end 11ms in-memory 190x faster
Merge 3x 15MB PDFs ~4,200ms (Multiple file uploads) 240ms (Worker processing) 17.5x faster
Data Privacy & Compliance PII transits WAN & persists in cloud storage Air-gapped in browser RAM Zero exposure
Operational Infrastructure Cost High (Compute, S3 IOPS, Egress bills) $0.00 / Zero server compute 100% cost reduction
Offline Capability ❌ Fails immediately without network ✅ Fully operational via Service Worker Infinite resilience

Overcoming Client-Side Sandboxing Challenges

A local-first, in-browser architecture is not without its engineering hurdles. When you abandon server clusters, you take on browser-specific constraints:

1. Memory Ceilings and V8 Garbage Collection Spikes

A tab's V8 heap limit typically caps out between 2GB and 4GB depending on the platform. If a user drops twenty 80MB scanned documents into the browser, holding multiple uncompressed raster bitmaps in RAM will cause the tab to crash.

To solve this, we implement chunked stream processing and immediate object disposal:

  • We read files via ReadableStream interfaces.
  • We avoid converting entire files to base64 strings (which introduces a 33% memory footprint penalty).
  • Once pages are extracted or rendered to canvas, we explicitly dereference the source ArrayBuffer objects to allow the V8 garbage collector to reclaim allocations before loading the next chunk.

2. Font Subsetting and Vector Glyph Embedding

One of the trickiest parts of drawing signatures locally is embedding custom vector typefaces. If you embed a full TrueType font (.ttf) into a document every time someone types their name, the output file balloons by several megabytes. We implemented dynamic font subsetting: analyzing the exact glyphs used in the user's name, discarding the unused unicode character maps, and serializing only a fraction of the font table into the final document tree.


The Paradigm Shift: Why Edge-Only Architecture Matters

Every year, software engineering teams spend millions of dollars scaling serverless orchestration, managing Kubernetes clusters, and mitigating database vulnerabilities—often to do things the client device was already capable of doing faster, cheaper, and more privately.

By treating the browser as a hardened, multi-threaded execution sandbox rather than a thin presentation layer, we completely eliminate server infrastructure costs. There are no AWS Lambda cold starts, no egress surcharges, no data breach liabilities, and no subscription paywalls for basic file actions.

👉 Try the live tool here: https://handlemyfile.com


Open Discussion for Senior Engineers

As WebAssembly runtimes mature and browsers gain direct access to hardware-accelerated WebGPU and WebAssembly SIMD primitives:

  1. Where do you draw the line between client-side browser execution and edge serverless workers (Cloudflare Workers, Fastly Compute@Edge) in your stack?
  2. What architectural barriers still prevent your team from adopting local-first, zero-backend processing for user files?

Let's discuss in the comments below.

Top comments (2)

Collapse
 
launchgatecheck profile image
Launch Gate •

I'd add a failure fixture beside the merge benchmark: first input valid, second password-protected, third malformed. What does the UI report, and can it ever offer a partial output as a successful merge? The sample uses ignoreEncryption: true, so I'd make the supported-input boundary explicit rather than present that flag as recovery. For large scans, peak memory is another useful measurement: the sample receives every input buffer, builds a target document and then serializes a new buffer. A worker keeps the UI responsive, but doesn't by itself show bounded memory usage.

Collapse
 
wolf2016 profile image
wolf Valencia •

Dereferencing the source ArrayBuffers before pulling the next chunk is the kind of detail that separates this from most in-browser PDF tools, which tend to fall over around the twentieth scanned file.

One thing works against the privacy story, though. The homepage loads Google Analytics (gtag) as soon as the page opens, and I couldn't find a cookie consent banner or any consent-mode setup. When the whole pitch is that files never leave the device, a privacy-minded visitor or an EU user is going to notice the tracking before anything else. A consent banner, or a cookieless analytics setup, would close that gap.

I'm a Staff SDET, so I ran a structured QA pass over the public site while reading. Overall it scored 96/100 (A), which is solid, and beyond the consent item there were 6 other smaller warnings worth a look when you get a minute.

Is the client-side OCR running on a WASM build of Tesseract, or something lighter?