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.
Writing it down in case it saves someone else the same time.
The constraint
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.
Three engines, one tab
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.
Cross-origin isolation, and why you need it
The threaded ONNX build needs SharedArrayBuffer, and SharedArrayBuffer needs the page to be cross-origin isolated. That means these two headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
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.
Memory is the whole game
This is the part I'd underestimated.
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.
Three things fixed it:
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.
Editing text that's already in a PDF
This is the feature people actually ask for and it's where most tools cheat.
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.
Two things that will ruin your day:
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.
Rotated pages break every assumption. A page can be rotated 90, 180 or 270 degrees, and the text coordinates are expressed in the unrotated space. If you don't handle it explicitly, everything lands in the wrong place and it looks like a parsing bug rather than a transform bug.
OCR without a server
Scanned pages run PP-OCR on ONNX Runtime, entirely client-side: a detection model, an orientation classifier, and script-specific recognition models. There are twelve of those, covering Latin, Arabic, Chinese, Cyrillic, Devanagari, Greek, Korean, Tamil, Telugu and Thai.
Running neural inference in a tab was the part I was least confident about. It's fine on desktop and acceptable on a mid-range phone. Models are fetched on demand rather than upfront, because shipping every script to every user would be absurd.
The output is an invisible text layer aligned behind the scanned image, so the result is a searchable, copy-pasteable PDF that never left the machine.
The CJK trap
Worth its own heading because the failure mode is so unhelpful.
If the character maps aren't loaded correctly, a Chinese, Japanese or Korean PDF renders zero characters. Not garbled text, not boxes — nothing. The page looks blank and you'll assume the file is broken.
If you're using pdf.js, this is the cMapUrl and cMapPacked configuration. If you serve your own assets and forget to ship the cMaps directory, every CJK document in the world silently fails for your users and nobody reports it, because from their side your app just doesn't work.
What I'd tell someone starting this
Budget your time for memory, not for parsing. The PDF spec is large but the libraries handle it; what nobody handles for you is a browser tab that has to hold three runtimes and a long document at once.
Turn on cross-origin isolation early, before you have a lot of resources to audit. Retrofitting COEP onto a page with many third-party subresources is tedious in a way that adding it on day one isn't.
And test with genuinely bad files. Corrupted XREF tables, subsetted fonts, rotated scans, mixed scripts. The happy path is easy and tells you nothing.
The thing you can check yourself
The claim "nothing is uploaded" is easy to make and easy to fake. The honest version is that you shouldn't take my word for it:
Load the page, disconnect the network, and keep editing. It carries on working. A tool that ships your file to a server physically cannot do that.
Or open devtools, Network tab, and run any operation. Your document never appears in a request.
The result of all this is at pdfnolimit.com — free, no account, no page limit. It is not open source, which I'll accept as a fair criticism; the offline test is the only proof I can offer in its place.
Happy to answer anything about the memory side in particular. That's where nearly all the time went.













