DEV Community

Madhav Mallya
Madhav Mallya

Posted on

Building a 100% Client-Side PDF Toolkit with WebAssembly: Lessons from 70+ Tools and 2k Weekly Users

A few months ago I uploaded a payslip to a "free PDF compressor" to fit it under an IRS portal's 2 MB limit. Then I read the privacy policy and saw they retained uploads for "service improvement" indefinitely. That was the moment I decided every PDF tool I'd build going forward would run entirely in the user's browser — no upload, no backend, no server touching the file at any point.

A few months later that became ExactPDF — 70+ PDF tools, ~1,300 weekly users, ~80% organic traffic, infrastructure under $40/month. This post is the architecture and the things that broke along the way.

The contract: open DevTools, prove it

The whole differentiator hinges on a single verifiable claim: your file never leaves your machine. Anyone can prove it — open DevTools, Network tab, process a 100 MB PDF, observe zero outbound requests with the file payload. The competitors on Google's first page (you know the ones) cannot pass this test. That's the moat.

Stack

The actual processing layer:

  • pdf-lib for manipulation — merge, split, rotate, compress, watermark, page reordering
  • PDF.js for rendering — thumbnails, page extraction
  • Tesseract.js in a Web Worker for OCR
  • Transformers.js with a DistilBERT QA model for chat-with-pdf
  • FFmpeg.wasm for the read-aloud feature (text → MP3)

The shell: Next.js 14 App Router, TypeScript, MUI v5, deployed on Cloud Run.

Three problems that ate a week each

1. Tesseract.js froze the UI on every long document

The naive integration runs OCR on the main thread. On a 200-page scan, the whole tab locks up for 60+ seconds, scroll jitters, the user assumes it's broken and refreshes.

Fix: spin up Tesseract in a Web Worker and post the page buffer in. Main thread stays responsive, progress updates stream back, the OCR tool handles 500-page scans without breaking.

The only gotcha: on mobile (Pixel 6 in my testing) Tesseract's WASM heap blows past Chrome's per-tab limit around 60-80 pages. Workaround is chunking — process 25 pages at a time, free buffers between batches.

2. PDF.js loading via ES module silently failed on iOS Safari

// Don't do this:
const script = document.createElement('script')
script.type = 'module'
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/...'
Enter fullscreen mode Exit fullscreen mode

iOS Safari's strict-MIME-type enforcement on ES modules killed this for ~15% of users. No console error, just a silent fail to render thumbnails on the merge tool and split tool.

Fix: load the standard (non-module) UMD build from cdnjs, no type="module". Works on every browser.

3. SharedArrayBuffer needs COOP + COEP, which breaks third-party scripts

FFmpeg.wasm requires SharedArrayBuffer, which since Spectre/Meltdown needs:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Enter fullscreen mode Exit fullscreen mode

Set those globally and Razorpay Checkout, Google Analytics, and a dozen other third-party scripts break because they fail the COEP check.

Fix: scope the headers to the single route that needs them. Next.js makes this clean:

async headers() {
  return [{
    source: '/(|[a-z]{2}/)tools/pdf-read-aloud',
    headers: [
      { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
      { key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' }
    ]
  }]
}
Enter fullscreen mode Exit fullscreen mode

Everywhere else still loads ads/analytics/Razorpay normally.

The expensive SEO lesson

In May I rolled out 8 high-traffic tools across 9 locales using thin, partial translations from English. Within 8 days, GSC's indexed-pages count dropped from 477 → 345 (a 28% loss). Google had identified the locale variants as duplicates because the translated metadata only changed about 14% of the 8-word shingles relative to canonical English — well below Google's "this is a different document" threshold.

Lesson: don't ship localized URLs into the sitemap until message-file parity is real. I have a translation-gap reporter now (npm run i18n:gap) that fails the build if a locale's parity drops below a threshold. The 8 tools are still English-only until proper translations land — better an English page that ranks than a Hindi page that gets deduped.

Result

ExactPDF is browser-only across all 70+ tools. The 4 I lean on most myself — and that you can stress-test against the "open DevTools, watch the network" claim:

Plus a headless API (@exactpdf/mcp on npm) so AI agents in Cursor / Claude Desktop / Codex can drive the same processing pipeline without the browser shell.

Free for personal use, 20 free API credits/month for new accounts.

If you're building anything privacy-first or WASM-heavy and want to swap notes on edge cases, drop a comment — especially curious about how others are handling Tesseract.js memory pressure on mobile.

Top comments (0)