Every online PDF tool asks you to do the same thing before it does anything useful: upload your file. Merge two PDFs, compress a scan, redact a page — step one is always the same button, and it always sends your document to someone else's server first.
For a lot of PDFs that's a fine deal. For a signed contract, a payslip, or a medical report, it is exactly the wrong one. Those files carry information that is regulated in most jurisdictions, and "we delete it afterward" is a promise about timing, not about whether the upload happened. We build Vellum, a set of free PDF tools, on the opposite premise: the file never leaves the device, because there's nowhere for it to go.
This post is about how that's actually implemented — not as a policy statement, but as an architecture that makes the upload structurally impossible.
The architecture
Vellum is a static site. No backend, no API, nothing listening for a file. Everything that used to be "send to server, run a tool, send back" now happens as WebAssembly and JavaScript in the tab.
Three engines carry the actual PDF work, each loaded lazily so a user opening the "rotate pages" tool doesn't pay for OCR they'll never touch:
-
pdf.js (
pdfjs-dist) renders pages to canvas and extracts text/structure. It's Mozilla's own PDF engine, already shipped in every Firefox and Chrome tab — we just load it ourselves instead of trusting the browser's built-in viewer. - pdf-lib manipulates PDF structure directly: merging, splitting, page operations, metadata, form filling.
-
Tesseract, compiled to WebAssembly via
tesseract.js, does OCR. The worker, the WASM core, and every language model are self-hosted under/tesseract/— nothing is fetched from a CDN:
// src/lib/ocr.ts
export async function createOcrWorker(lang: OcrLang, onProgress?: (p: OcrProgress) => void) {
return createWorker(lang, 1, {
workerPath: `${TESS_BASE}worker.min.js`,
corePath: TESS_BASE,
langPath: `${TESS_BASE}lang`,
gzip: true,
cacheMethod: 'none',
})
}
That last point matters more than it looks: most tesseract.js examples pull the core and language data from a public CDN. Doing that would mean every OCR run makes an outbound request to a third party — a small crack in a promise that's supposed to be structural, not best-effort.
Encryption, decryption, and file repair go through qpdf, also compiled to WASM, loaded from /qpdf/ with a bundler-ignored dynamic import (Emscripten's loader relies on global side effects that Vite/Rollup would otherwise break):
// src/lib/qpdf.ts
function loadQpdfInit(): Promise<QpdfInit> {
if (!initPromise) {
initPromise = import(/* @vite-ignore */ `${QPDF_BASE}qpdf.mjs`).then(
(m) => m.default as QpdfInit,
)
}
return initPromise
}
A fresh qpdf instance is spun up per operation — the Emscripten runtime isn't reusable after callMain — but the .wasm binary itself sits in the browser's HTTP cache, so repeat use doesn't re-download it.
The part we'd rather not have to say out loud, but which is the whole point: the promise isn't enforced by a privacy policy page. It's enforced by the Content-Security-Policy header, which is the same for every response:
connect-src 'self'
That single directive means the browser will refuse any fetch, XHR, WebSocket, or resource load to a third-party origin — not "we chose not to," but "the browser won't let the page do it even if the code tried." You can verify this yourself with the Network tab open during any operation; that's the whole audit.
The hard parts
None of this is free. A few problems only show up once you actually try to do PDF work client-side.
Redaction that's real, not cosmetic. A black rectangle drawn over text in a PDF viewer is famously not redaction — the text is still there, selectable and copy-pasteable underneath. Vellum's redact tool rasterizes each page to an image, paints the redaction boxes directly onto the pixels, and rebuilds the PDF from the images. The original text object simply no longer exists in the output file:
// src/lib/redact.ts
const rendu = await rasterisePage(src, i, {
dpi,
retoucher: (canvas, ctx) => {
ctx.fillStyle = '#000000'
for (const b of boxes.filter((z) => z.pageIndex === i)) {
ctx.fillRect(b.x * canvas.width, b.y * canvas.height, b.w * canvas.width, b.h * canvas.height)
}
},
})
The tradeoff is honest and unavoidable: the output has no selectable text anymore. That's the price of redaction you can actually trust.
PDF/A conformance, proven rather than claimed. PDF/A is the ISO standard for long-term archival, and a lot of tools claim to produce it without anything checking that claim. Vellum has two modes. The "faithful" mode preserves selectable text and improves what it can (removes embedded JavaScript, adds an sRGB output intent, flags any non-embedded fonts) but can't guarantee conformance, because that depends on the input document. The "conformant" mode rasterizes every page into an opaque image and reconstructs the file from scratch with no fonts and no dynamic content at all — which is the only mode whose ISO 19005-2 conformance is actually verified: a Node harness (scripts/validate-pdfa.mjs) builds a corpus of deliberately awkward PDFs (unembedded standard fonts, transparency, AcroForms, scanned images) and runs every output through veraPDF, the reference validator. It fails the build if the conformant mode doesn't pass on all of them.
Memory and loading size. Everything runs in the tab's own memory, so the honest limit is "however much RAM the browser gives this page" — a typical office document is comfortable, a two-gigabyte scanned atlas is not. On the loading side, qpdf and Tesseract's WASM binaries are sizeable, so the service worker treats them differently from the app shell: the shell (JS/CSS/HTML) is precached on install, while the WASM engines are cached on first use with a CacheFirst strategy and a 180-day expiration — you pay the download once, not the app-shell cost of bundling megabytes nobody asked for.
Offline without surprising anyone. The PWA can install and keep working with no connection at all, but it never reloads itself out from under a running conversion. registerType: 'prompt' means an available update sits quietly until the user dismisses a non-blocking toast — worth calling out because "autoUpdate" silently swapping the running app mid-task is exactly the failure mode a tool meant for offline, in-progress work can't afford.
What we measured
Claims about competitors are cheap, so we measured instead of asserting. On 2026-08-29 we dropped the same eight-page test PDF into the compression tool of the most-used online PDF services, with the browser's Network tab logging every outgoing request, then resolved each destination host to an IP and its hosting provider.
iLovePDF uploaded the file to api110.ilovepdf.com (OVH, Germany) immediately on drop — before any click on "Compress." PDF2Go uploaded to a Hetzner server in Nuremberg and fired a simultaneous request to Microsoft Clarity for session behaviour analytics. PDF24 Tools uploaded to Hetzner as well, while displaying a "Secure" badge on screen. Smallpdf is marked "not measured" — its native file picker defeated our automated method, and we'd rather leave a gap than guess. Vellum, running the same compression on the same file: zero network requests.
The full protocol and data are at vellumpdf.ch/en/etude. None of the measured services lied about eventually deleting the file — the point the data makes is that deletion happens after the upload, not instead of it.
The business model
The tools are free with no quota, because there's no server cost to ration against. Revenue comes from a separate B2B tier, Vellum Cabinet, aimed at law and accounting firms bound by professional secrecy: a signed processing attestation for their compliance records plus an on-premise build, sold alongside — not instead of — the free public tools.
Try it yourself
The claim in this post is falsifiable in about ten seconds: open any tool at vellumpdf.ch/en, switch off Wi-Fi, and process a file. It works, because nothing needed to leave in the first place — the same test you can run against any competing service, where it won't.
Edem Dogbe, DOGBE MULTISYSTEM, Switzerland
Top comments (0)