I spent the last few months moving a JavaScript PDF tool to WebAssembly, and the interesting problems turned out to have almost nothing to do with PDF parsing. They were about memory, isolation headers, and one silent failure mode that cost me a week.
The rule I set was that no document would ever be uploaded. Not "deleted after an hour", not "encrypted in transit" — never transmitted. Every free PDF tool I looked at sends your file to a server, which is fine for a recipe and less fine for a contract or a payslip.
That single constraint decides the entire architecture. You can't fall back to a worker queue when something is slow. You can't fix a user's broken file server-side. Whatever the browser can't do, the product can't do.
The work ended up split across three WebAssembly modules: PDFium (~4.6 MB) for parsing and rendering QPDF (~1.3 MB) for encryption, decryption and repairing damaged files ONNX Runtime Web, SIMD + threaded build (~11.8 MB) for OCR inference
Plus pdf.js for the text layer and a handful of JS libraries for Office formats.
Loading three runtimes into one page is less painful than it sounds. The download is cached and mostly parallelisable. What is painful is what they do to memory once they're all live, which I'll come back to.
The threaded ONNX build needs SharedArrayBuffer, and SharedArrayBuffer needs the page to be cross-origin isolated. That means these two headers:
I used credentialless rather than require-corp because require-corp demands that every cross-origin subresource opts in with CORP headers, and in practice a lot of them don't.
Here's the part that cost me time: when COEP breaks a resource, it breaks it quietly. No console error you'd notice, no network failure that stands out. An image just doesn't appear, or a script silently doesn't run. If you turn this on and something stops working for no visible reason, COEP is your first suspect.
Check crossOriginIsolated in the console. If it's false, threads aren't actually running and you've paid the isolation cost for nothing.
A naive implementation keeps rendered pages in the DOM. That is fine for a twenty-page invoice and fatal for a three-thousand-page document — Chrome kills the tab, and from the user's side it just looks like your app crashed.
Virtualized viewport rendering. Only pages near the viewport exist as rendered surfaces. Everything else is a placeholder with correct dimensions so scroll position stays honest.
Recycling raster buffers. When a page leaves the window, its buffer isn't freed and reallocated — it goes back into a pool and gets reused by the next page that enters. Allocation churn was a bigger cost than I expected.
Letting WASM memory pools do their job. Repeatedly growing linear memory is expensive and it never shrinks back. Sizing pools deliberately and reusing them beats growing on demand.
After that there's no page limit in the product, because there's nothing arbitrary left to cap. The ceiling is the machine's RAM.
The two common cheats: draw an HTML text box on top of the canvas (looks fine until you export), or replace the text and substitute a default font (looks wrong immediately).
Doing it properly means reading the embedded /FontDescriptor, the character maps and the glyph metrics straight out of the content stream, then re-measuring line widths, baseline offsets and kerning when the text changes so the rest of the paragraph doesn't shift.
Text arrives fragmented. Extraction hands you pieces with unreliable spacing — sometimes a space is a space, sometimes it's a positioning offset between two runs. Reassembling paragraphs needs adaptive thresholds based on the font size and the actual gaps, not a fixed value. Every fixed threshold I tried was wrong on some document.
