A PDF merge tool went around my team last month. The headline was "your files never leave your device." I opened devtools out of habit. The page loaded a Google font, a session recorder, and a Sentry bundle, and it POSTed a page_view with the document title in the payload. The PDF itself never left. The filename, the page count, and the fact that I was merging a contract at 11:40 p.m. did.
The tool was not lying, exactly. It merged the files locally, which is a real engineering achievement and the reason I use these things. But "zero upload" is a security claim, and a security claim is worth what its enforcement is worth.
The architecture is right, and that's the frustrating part
Moving file processing into the browser is one of the better ideas to come out of the last few years. PDF parsing via a WASM build of pdfium or qpdf, image resizing on a canvas, CSV work in plain JS, zip handling with a streaming library. No server means no retention policy, no data processing agreement, no S3 bill, no bucket that someone forgets to make private. For a 12 MB PDF, the whole job fits in the tab.
Then the marketing outruns the engineering. "Bank-grade privacy" appears next to a page that calls four third parties before the first paint.
The gap is always the same three things: analytics, error reporting, and fonts. Sentry is the worst of them because its payloads are designed to carry context. File names, breadcrumbs, and the last network request can all end up in an event, and nobody reads the scrubbing config until a customer asks.
Make the claim enforceable
If you want to say the files never leave, put it in a header where it is checkable:
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'none'; img-src 'self' data: blob:; font-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none' connect-src 'none' blocks fetch, XHR, WebSocket, EventSource, and navigator.sendBeacon. That is the actual guarantee. Not the copy, not the privacy policy, the header. 'wasm-unsafe-eval' is there because compiling a WASM module is blocked by a strict script-src without it, which is a detail people discover at 2 a.m. the night before launch.
form-action 'none' and base-uri 'none' close the two dumb paths around connect-src. And if you truly ship no telemetry, put a comment in the repo explaining that the header is a product promise, so the next person who adds a chat widget gets a failing test instead of a silent policy change.
If you cannot set connect-src 'none' because you need a support chat and a payment SDK, that is fine. Say "processed locally, no files uploaded" instead. That sentence is true and defensible. The other one is a promise your own CDN can break.
The constraints nobody puts in the demo
The privacy story gets all the attention. The engineering is harder than the landing page suggests, and the limits are predictable:
Memory. WASM32 gives you a 4 GB address space in theory and browsers give you far less in practice, especially on mobile, where the tab gets killed without a warning you can catch. A 300 MB scanned PDF will not fit if you decode every page at once. You need to process page by page, write intermediate results to OPFS, and free aggressively. If your demo file is 200 KB, you have not tested the product.
Streaming. File.stream() gives you a ReadableStream, and TransformStream lets you pass chunks to a worker without materializing the whole file. That works beautifully for line-oriented formats, hashing pipelines, and archives. It does not work for PDFs, because PDF structure is random access by design, the xref table can live at the end of the file, and incremental parsing gets ugly fast. Know which case you are in before you promise streaming.
Hashing. WebCrypto's crypto.subtle.digest takes a full buffer, which defeats the point of streaming a 2 GB file. If you need an incremental SHA-256 you will end up pulling in a WASM implementation or a pure-JS one that is slow enough to notice. I have lost an afternoon to this more than once.
Jank. Parsing 400 pages on the main thread freezes the UI and Safari will helpfully kill the page for you. Worker plus postMessage with transferable ArrayBuffers, always. If your worker copies instead of transfers, you will watch memory double for no reason.
What I'd want to see before trusting one
A page that merges my files locally and proves it. Three things make that checkable in under a minute: the CSP header, a network tab that stays empty while the file processes, and source you can read or a build you can diff. If the tool is closed source, the header plus an empty network panel is still most of the way there, and it is far more than the privacy policy tells you.
None of this is an argument against the architecture. It is an argument against describing a local-first tool with the vocabulary of a security product while shipping the telemetry of an ad-supported one.
The PDF merge tool from my team's Slack got the second half right. The files stayed on the machine. The metadata did not, and nobody had written down which one the promise covered.