OxygenPDF
engineering
webassembly
performance

Building PDF Tools with WebAssembly in the Browser

RohmanRohman12 min read
Building PDF Tools with WebAssembly in the Browser

Short answer: OxygenPDF runs PDF processing entirely client-side using three layers — pdf-lib for structural edits (merge, split, rotate, watermark), PDF.js for rendering and text extraction, and WebAssembly for the operations plain JavaScript is genuinely too slow for: OCR (Tesseract.js and PaddleOCR) and a custom Rust-compiled PDF-inspection engine. Nothing gets uploaded; the trade-off is that your device does the work your device's browser can actually do.

When we started building OxygenPDF, the assumption was that serious PDF processing needs a server. Poppler, QPDF, Ghostscript — C/C++ libraries that have been doing this for decades. Browsers just display PDFs, right?

Turns out, the browser can do a lot more than people give it credit for — and knowing which parts of that work actually need WebAssembly, versus which parts are better off as plain JavaScript, turned out to matter more than we expected going in. The privacy case for keeping all of this on-device is a separate post — Why Local-First PDF Tools Matter. This one is about the engineering.

WebAssembly (WASM) is a binary instruction format, standardized by the W3C, that runs inside a memory-safe, sandboxed execution environment at close to native speed. It's not a replacement for JavaScript — it's a compilation target for languages like Rust and C++ that lets code originally written for a desktop OCR engine or a PDF parser run inside a browser tab with no server round-trip.

The Browser PDF Stack

pdf-lib

pdf-lib is a pure JavaScript library for creating and modifying PDFs. It handles merging, splitting, rotating, extracting pages, adding text and images, modifying metadata, and embedding fonts with Unicode support. Everything runs in memory using ArrayBuffer and Uint8Array, which makes it a natural fit for the browser — no native bindings, no filesystem, no subprocess.

Every one of those structural operations in OxygenPDF runs on plain JavaScript. There's no WebAssembly involved in merge, split, compress, or password removal — pdf-lib's page-copying and byte-serialization work is fast enough on its own that reaching for WASM there would add a compilation and marshaling cost with nothing to show for it.

PDF.js

Mozilla's PDF.js is the same engine Firefox uses to display PDFs. We lean on it for rendering page previews in our PDF reader, extracting text for search and OCR-skip detection, parsing document structure, and handling encrypted files. PDF.js ships its own dedicated Web Worker — set via GlobalWorkerOptions.workerSrc — so parsing and rendering already happen off the main thread by default, before any tool-specific code runs.

WebAssembly

This is where WASM earns its place, and it's narrower than the title of this post might suggest. Two jobs in OxygenPDF are compute-heavy enough that we compile native code to WASM instead of writing it in JavaScript:

  • OCR. Tesseract.js compiles the Tesseract OCR engine — originally a C++ project — to WebAssembly. For a second, AI-assisted option, PaddleOCR's PP-OCRv5 detection and recognition models run through ONNX Runtime Web, which executes ONNX models via its own WASM backend so it works in any modern browser without requiring WebGPU.
  • PDF structure analysis for document conversion. pdf-to-word, pdf-to-markdown, pdf-to-excel, and pdf-to-text share a Rust crate we compile to WebAssembly with wasm-pack. It classifies whether a PDF is text-based or scanned, extracts positioned text, and detects table structure — work that's a much better fit for Rust's string- and layout-handling than for hand-rolled JavaScript, and that needs to run fast enough to analyze a multi-page document without a visible stall.

Everything else — the interactive AI-assisted OCR mode (Florence-2, via Transformers.js) — runs through WebGPU instead of WASM when a browser supports it, falling back to the PaddleOCR/WASM path on iOS's WKWebView and on browsers without WebGPU exposed. For OCR jobs that genuinely exceed what any of these can do quickly, there's an optional cloud fallback (GOT-OCR, Surya, and a general text-extraction model, proxied through our own API route so the request never touches a third-party server directly from your browser).

What Doesn't Need WebAssembly

It's worth being explicit about this, because it's easy to assume "browser PDF tool" means "everything is WASM under the hood." It doesn't, and that's deliberate. Compressing a PDF in OxygenPDF, for example, renders each page to a <canvas> at a target DPI and re-encodes it as JPEG using the browser's own native canvas.toBlob('image/jpeg', quality) call — not a WASM-compiled image codec. Chrome, Firefox, and Safari all ship a heavily optimized, hardware-accelerated JPEG encoder as a browser primitive; writing or embedding a WASM codec to duplicate that would trade a fast native implementation for a slower one, just to say the word "WebAssembly" in the changelog.

The rule we ended up following: reach for WASM when you need to run existing, non-trivial native code (a whole OCR engine, a Rust parser) that would take months to reimplement faithfully in JavaScript. Don't reach for it when the platform already exposes a fast primitive for the same job. A merge operation copying page objects, or a JPEG re-encode using a browser's built-in encoder, doesn't clear that bar.

Architecture Decisions

Web Workers

Not every operation in OxygenPDF runs on a dedicated Web Worker, and the distinction matters. PDF.js's parsing and rendering pipeline runs on its own worker automatically. OCR is the other place workers matter most: Tesseract.js spins up a worker pool — sized to navigator.hardwareConcurrency, capped at four on web and two inside our iOS app (each worker holds roughly 15 MB of loaded language data, and the tighter iOS WKWebView memory budget doesn't leave room for four) — and distributes pages across them so a multi-page scan recognizes in parallel instead of one page at a time.

Structural operations built on pdf-lib — merge, split, rotate, add-page-numbers — don't spin up a separate worker at all. They run as async functions on the main thread, awaiting each pdf-lib call and reporting progress through a callback between steps. For the file sizes these operations typically handle, the await boundaries between page copies are enough to keep the event loop free for scrolling and clicking; a merge of ten files that would take 200 ms doesn't need the overhead of postMessage-ing bytes to a worker and back.

Web Worker architecture — heavy work (PDF.js parsing, WebAssembly OCR recognition) is offloaded to a worker while pdf-lib's own operations run inline on the main thread between progress updates

Streaming for Large Files

Loading a 100+ page PDF into memory all at once, then rendering every page at full resolution, is asking for trouble. Instead, every place in the app that rasterizes a page goes through one shared budget: a single canvas edge is capped at 8,192 device pixels and total canvas area at roughly 33.5 megapixels. A page that would need a bigger canvas than that — an oversized engineering drawing, say — gets rendered as overlapping horizontal strips ("bands") instead of one giant bitmap, then stitched back together, so a 420-inch blueprint doesn't crash the tab the way a naive single-canvas render would. OCR uses the same banding approach, with a 128-pixel overlap between bands so a line of text sitting on a band boundary isn't cut in half and misread as two fragments.

Progressive Enhancement

Not every browser supports every feature, so OxygenPDF layers capability instead of requiring the best case. Core structural operations (merge, split, reorder) are plain JavaScript and work everywhere a modern browser runs. OCR has three tiers: Tesseract.js and PaddleOCR's WASM path work in any browser; Florence-2's AI OCR mode uses WebGPU where it's exposed (skipped automatically on iOS's WKWebView, which doesn't expose WebGPU, and on desktop browsers that don't support it); and cloud OCR engines are available as an opt-in fallback for documents that need more than an on-device model can reliably deliver.

Problems We Ran Into

Memory

WebAssembly compiled for its standard 32-bit target (wasm32 — what Tesseract.js and ONNX Runtime Web both use) is capped at 4 GB of linear memory per module. That's a hard limit in the WebAssembly format itself, not a browser vendor's choice — V8's engineering team has written about the ceiling and the work that went into supporting the full 4 GB. We've never come close to it in practice: even OCR at our highest quality preset (a 3.5x render scale) produces a canvas well under a hundred megabytes per page, orders of magnitude below that ceiling. The more common failure mode is a browser's own canvas limits, not WASM's — which is exactly what the banded rendering described above exists to avoid.

Fonts

PDF font handling is genuinely awful. Embedded subsets, CID fonts, Type1, TrueType collections — the format has accumulated decades of font technology, and pdf-lib's built-in StandardFonts only cover WinAnsi/Latin-1: they throw on a decomposed umlaut and can't draw Chinese, Arabic, or Devanagari text at all. We replaced that path with our own font-embedding layer: text is Unicode-normalized (NFC), split into runs by script, and each run is drawn with a matching embedded typeface — a bundled Inter face for Latin, Cyrillic, and Greek, and Noto faces loaded on demand for CJK, Korean, Arabic, Hebrew, Devanagari, and Thai. The smaller non-Latin faces (roughly 365 KB combined) ship inside the app itself so they don't depend on a CDN being reachable; the two CJK faces are large enough (about 18 MB for Simplified Chinese, 10 MB for Korean) that bundling them into every deploy isn't worth it for scripts most visitors never touch, so those are fetched from a version-pinned CDN URL on first use and cached in the browser's IndexedDB afterward.

One honest limitation: pdf-lib has no text shaping engine, so while Arabic, Hebrew, Thai, and Devanagari text draws with the correct glyphs, right-to-left reordering and complex script joining aren't applied — a limitation of the rendering layer, not something WebAssembly would fix on its own, since text shaping is a layout problem rather than a raw-throughput one.

Encryption

A lot of PDF libraries offer an ignoreEncryption flag that sounds like it decrypts the file. It doesn't — it just skips the encryption marker in the file header and lets pdf-lib read a document it can't actually parse correctly, which produces corrupted or empty output. Real decryption in OxygenPDF goes through PDF.js, which can open both user-password PDFs (need a password to view) and owner-password PDFs (view freely, but with edit restrictions pdf-lib can't process directly either way).

For tools that hand bytes to pdf-lib for structural work — merge, split, watermarking, protecting or unprotecting a file — we decrypt by rendering every page through PDF.js at 2x scale (144 DPI) and rebuilding a brand-new, unencrypted PDF from those images with pdf-lib. That's a genuinely different document afterward: every page is now a raster image, which is exactly what pdf-lib needs and doesn't care whether the underlying content was vector text or a scanned photo to begin with.

The trap we had to design around deliberately: that same rasterizing decryption path is wrong for anything that needs to read the PDF's text — pdf-to-word, pdf-to-excel, OCR's "skip if there's already text" check. Run a password-protected file through the rasterize-and-rebuild path first, and a text-reading tool sees a blank scanned image with zero extractable characters, no matter how much real text the original PDF had — a confident, silent wrong answer rather than a crash. The fix is routing the password straight into the reader instead: both pdfjs-dist's getDocument({ data, password }) and our Rust PDF-inspection engine decrypt in memory and hand back real text and table structure, without ever converting the page to a picture of itself.

Performance Numbers

These are approximate timings from our own testing in September 2026, in Chrome on an Apple-silicon MacBook — not a formal benchmark suite, and results will vary with file complexity, image density, and device. Every row is reproducible by running the corresponding tool on this site:

Operation Time
Merge 10 PDFs (few pages each) ~200ms
Split a 100-page PDF ~500ms
Compress with image optimization 2-5s (varies with image count and resolution)
OCR a single page (Tesseract, balanced quality) 3-8s

Skipping the network round-trip matters more than the raw CPU numbers suggest. CPU time per operation can be higher than a beefy server's, but wall-clock time is often lower because there's no upload, no queue, and no download — the bottleneck a server-side tool pays on every request simply doesn't exist here.

Frequently Asked Questions

Does client-side PDF processing actually work offline?

Structural operations (merge, split, rotate, pdf-lib-based edits) and the default Tesseract/PaddleOCR OCR engines run entirely on-device once the page and its WebAssembly modules have loaded, with no server round-trip required. The exceptions are the CDN-hosted Noto font files and OCR model weights, which are fetched once and cached (IndexedDB for OCR models and non-bundled fonts) so repeat use doesn't need a connection either — and the optional cloud OCR engines, which are opt-in and require a network request by design.

Is browser-based OCR as accurate as desktop OCR software?

It depends on the engine and the source image, the same as it would server-side. Tesseract.js is the same OCR engine — compiled to WebAssembly rather than to a native binary — used by many desktop and server tools, so its accuracy characteristics carry over unchanged. PaddleOCR's PP-OCRv5 models add stronger detection on dense or low-contrast layouts. Running in the browser doesn't change the underlying recognition accuracy; it changes where the computation happens.

Why use WebAssembly for OCR instead of calling a cloud API?

Local WASM-based OCR keeps a scanned page — which is often the very tax form, contract, or ID document a user doesn't want uploaded anywhere — entirely on their device. It's also usually faster for a single page or a short document, since there's no upload, queueing, or download step. The trade-off is that very large batch OCR jobs, or documents needing the highest possible accuracy from a large multimodal model, can genuinely benefit from cloud compute, which is why OxygenPDF offers cloud OCR engines as an explicit opt-in rather than a default.

What happens to a password-protected PDF when a tool decrypts it?

It depends on what the tool needs to do with the file afterward. Structural tools (merge, split, protect, unprotect) decrypt by rendering every page to an image and rebuilding a new PDF from those images — the output opens without a password but is now a raster document. Tools that need to read the text or table structure (conversion, OCR's text-detection step) instead pass the password directly to the parser, which decrypts in memory without ever converting the page to a picture, so the real text and layout survive.

Can a browser tab run out of memory processing a large PDF?

Yes, in principle, though it's rarer than it sounds. A browser tab's practical memory ceiling is a few GB in practice, varying by browser and platform, and WebAssembly's own wasm32 linear memory cap tops out at 4 GB per module — far above what a single-page OCR or render operation needs. In practice, the more common constraint is a single canvas exceeding a browser's maximum dimensions, which OxygenPDF avoids by capping canvas size and rendering oversized pages as stitched strips instead of one giant bitmap.

What We're Watching

WebGPU has already moved from "future possibility" to production use in OxygenPDF — it's what runs Florence-2, our AI-assisted OCR mode, wherever a browser exposes it. The Origin Private File System is next on our list: it offers synchronous, byte-level file I/O from inside a worker, which MDN describes as faster than the promise-based File System Access API precisely because it skips that overhead — useful for a tool streaming a very large PDF through disk-backed storage instead of holding it all in memory. SharedArrayBuffer-based true multi-threading is the other one we're watching, though it requires serving the page as cross-origin isolated (specific Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy response headers) — a real deployment cost we'd need to weigh against the benefit for our particular workloads before adopting it.

The gap between what a server can do and what a browser can do gets smaller with every browser release. We're betting on that trend continuing — and on being honest, in posts like this one, about exactly which parts of the stack are WebAssembly and which parts never needed to be.

Rohman

Written by

Rohman

Rohman builds OxygenPDF's browser-based PDF engine — the pdf-lib, PDF.js, and WebAssembly plumbing this post walks through, plus the Rust/WASM PDF-inspection crate that powers its document-conversion tools.

Share this articlePost on XLinkedIn

Stop renting your PDF platform.

All 119+ tools free on web. Desktop Pro is $29 once — every desktop tool, the workspace, and batch processing.

  1. $29now
  2. $79after that

14-day money-back guarantee • Works offline • All platforms

We use analytics to understand how our tools are used and improve the experience. No personal files are ever sent.