# Browser-side PDF Q&A without uploading the file
Why we stopped uploading PDFs to servers for AI processing, what the privacy story looks like when files stay in the browser, and how Linda's parsers do it in 1 KB of glue + lazy WASM.
**Published:** 2026-05-22  
**Tags:** privacy, parsers, pdf, browser  
**Cluster:** supporting  
---
We host a SaaS where users drop PDFs and chat with them. For two years we did it the way everyone does: file goes to S3, server parses with pdf-lib or pdfplumber, embeds chunks, retrieves them at query time. It works. It also costs us a 7-figure annual S3 + GPU bill, requires HIPAA paperwork for our healthcare customers, and generates security questionnaires every quarter.

Six months ago we shipped the same UX with no uploads. The PDF stays in the browser. Costs collapsed. Security questions collapsed. The product got faster.

Here's what changed, and why we think browser-side processing is the default for any LLM feature that touches user files in 2026.

## The math that broke the server-upload model

Per-file processing cost:

| | Server-side | Browser-side |
|---|---|---|
| Storage (S3) | $0.023/GB-month | $0 |
| Parsing CPU | $0.04/minute on Lambda | $0 (user's CPU) |
| Embedding | $0.13/M tokens (OpenAI) | $0 (transformers.js, optional) |
| Bandwidth | $0.09/GB egress | $0 |
| Compliance | $$$$$ | $0 (no PII transit) |

The browser-side column isn't zero of course — the user pays for their CPU/RAM. But at the *aggregate* level, you're shifting compute from a centralized server (where you pay for the worst case) to the user's device (where they pay for their own request). Linear instead of N²-ish.

The compliance column is the bigger story. If the file never reaches your server, you have nothing to attest to about handling it. No DPA. No BAA. No data residency questions. No "what if you get breached" answers. It's a cleaner posture.

## How it actually works

Linda's parser packages are thin shims:

```ts
// @linda/parsers-pdf — the whole thing is ~30 lines.
import { defineParser } from "@linda/parsers";

export const pdfParser = defineParser({
  name: "pdf",
  mimes: ["application/pdf"],
  requires: ["FileReader"],   // capability gate
  async parse(file) {
    // Lazy-import the heavy dep so the 700 KB only loads when actually used.
    const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");

    const buf = await file.arrayBuffer();
    const doc = await getDocument(buf).promise;

    const pages: string[] = [];
    for (let i = 1; i <= doc.numPages; i++) {
      const page = await doc.getPage(i);
      const content = await page.getTextContent();
      pages.push(content.items.map((it: any) => it.str).join(" "));
    }

    return {
      text: pages.join("\n\n---\n\n"),
      artifacts: { "pages.jsonl": pages.map((t, i) => JSON.stringify({ page: i + 1, text: t })).join("\n") },
    };
  },
});
```

That registers a parser. When the user drops a PDF, the Linda file manager:

1. Detects the MIME.
2. Checks the capability gate (`FileReader` is universal).
3. Runs the parser.
4. Mounts the result at `/user/files/<id>/parsed/text.md` and `/user/files/<id>/parsed/pages.jsonl`.
5. The agent reads from those paths when answering questions about the file.

The raw bytes stay in browser memory. They never go anywhere unless you write a hook that sends them.

## The size question

pdf.js is ~700 KB gzipped. That's not small. But:

- It only loads when a user *actually* drops a PDF. Most sessions never load it.
- It's loaded once per session and cached by the browser.
- 700 KB is way less than 12 MB of React + your bundle, which the user is already paying for.

The pattern: a 1 KB shim that registers the parser, with the heavy dep `import()`-ed inside `parse()`. Tesseract.js (OCR) gets the same treatment — 2 MB of WASM + 6 MB language data, but only loaded when a user drops an image of text.

| Package | Shim | Heavy dep (lazy) |
|---|---|---|
| `@linda/parsers-pdf` | ~1 KB | ~700 KB (pdf.js) |
| `@linda/parsers-office` | ~2 KB | ~1.1 MB (mammoth + SheetJS) |
| `@linda/parsers-archive` | ~1 KB | ~30 KB (fflate) |
| `@linda/parsers-ocr` | ~1 KB | 2–10 MB (tesseract.js) |
| `@linda/parsers-ml` | ~2 KB | varies (transformers.js) |
| `@linda/parsers-audio` | ~2 KB | ~40 MB (whisper, WebGPU + COI) |

You ship the shims you need. The user pays the lazy load only when they use the feature.

## Privacy posture, plainly stated

When a user drops a PDF into a Linda app:

- The raw bytes stay in browser memory.
- The parsed text may be sent to the LLM provider (so it can answer questions). That's the trust boundary, and it's *the same provider you're already using for chat*.
- Nothing goes to Neul Labs. There's no Linda server in the middle.
- Nothing goes to your server unless you write a hook that sends it.

That's a clean story. You can explain it to a CISO in one paragraph. Compare to the server-side version: "we upload to our S3, we embed with our embeddings provider, we store in our vector DB, we retrieve at query time, we delete after N days, we have a DPA with each subprocessor, we're SOC 2 Type 2 compliant, here's our breach plan…"

The browser version doesn't need most of those answers because the question doesn't apply.

## Where it falls down

Browser-side processing isn't universal.

- **Server-side batch jobs.** If you want to process 10K PDFs at night, you need a server. Browsers aren't built for that shape.
- **Cross-user data fusion.** If your agent needs to read PDFs from multiple users to answer one question, the browser can't help — that data has to live somewhere shared.
- **Very large files.** A 500 MB scan PDF will OOM most browsers. Have a fallback.
- **Compliance that requires you to log file content.** Browser-side means you don't get to log the file. Sometimes that's a problem.

For the 80% of user-facing AI features that touch user files, though — drop, chat, get answer — browser-side is the default we should reach for.

## The shape we recommend

```
1. User drops file.
2. Browser parses with @linda/parsers-* (lazy WASM).
3. Parser output lands at /user/files/<id>/parsed/*.
4. Agent reads only what it needs from /user/files/<id>/parsed/.
5. Your `onFileUpload` hook persists hashes (not bytes) to your DB.
6. The file expires from browser memory when the chat closes.
```

That's the recipe. Five steps, zero servers, GDPR-friendly by construction.

If your AI product is taking a PDF and sending it to a server, ask yourself why. In most cases, the server-side step isn't doing anything the browser can't. It's a habit from when browsers couldn't parse files. Browsers can now. Make the small change.

Try it: drop a PDF into the [pdf-qna example](/use-cases/pdf-qna) and watch the network tab. No upload. Just chat.