Written 2026-09-04, about @stabrise/scaledp@0.1.1. No OCR background assumed.
TL;DR: "We delete it after processing" is a promise — it depends on a vendor's behaviour, and you verify it by reading a document. "It never left the tab" is a property — it depends on the data flow, and you verify it by opening the network panel. If you are building anything that handles documents people care about, the difference is worth designing for.
Promises versus properties
Here is a claim from a typical OCR vendor's security page:
Documents are processed in memory and deleted immediately after the response is returned. We do not use customer data for training.
I have no reason to doubt it. But look at what it takes to believe it: a policy document, an audit report, a contract with penalties, and trust in an organisation's future behaviour — including after an acquisition, a breach, or a change in leadership. Those are all real mitigations, and they are all promises.
Now the same claim for a client-side pipeline:
The document is never transmitted.
The verification is different in kind. You open DevTools, filter the network panel to XHR and fetch, drop a file in, and watch nothing happen. You can do that yourself, in thirty seconds, without asking anyone.
A promise constrains behaviour. A property constrains what is possible. Both are worth having; only one of them survives an organisation you cannot audit.
What "never left the tab" actually means here
Being precise, because overclaiming this is worse than not claiming it.
What genuinely never leaves: the document bytes, the rendered page images, the recognised text, the detected boxes, the extracted entities. All of it lives in JavaScript memory and OffscreenCanvas buffers inside one tab, is passed between stages as plain objects, and is garbage-collected when you drop the reference.
const pipeline = new Pipeline([
new PdfToImage({ resolution: 300 }),
new PaddleTextRecognizer({ keepFormatting: true }),
new GlinerNer({ labels: ['person', 'email', 'iban'] }),
])
// Every byte of this stays in the tab.
const rows = await pipeline.transform(file)
What does leave, once: the model weights, downloaded from a model host. That is an outbound request for a public artefact — it says "this origin fetched PaddleOCR v6-small", not "this user uploaded a contract". If even that is too much signal, you self-host:
configure({
modelHost: 'https://models.internal.example.com',
cache: 'indexeddb',
})
After which there is no third-party request at all. Which brings us to the demonstration.
The demo states its own runtime in a strip across the top, which is the same information your app should surface:
Two honest things are visible there. The inference is running on WebAssembly in this tab. And multithreading is unavailable, because the page is not cross-origin isolated — GitHub Pages cannot send the headers that would allow it. That is a real performance cost of the deployment, printed on screen rather than hidden.
How to prove it to a reviewer
This is the part that actually matters in a procurement conversation, and it takes about a minute.
- Load the app once with a network connection, and let the models cache. They go to IndexedDB, scoped to your origin.
- Turn the network off. Airplane mode, or DevTools' offline throttling.
- Drop a document in and run the pipeline.
- It works.
A system that produces correct output with no network cannot be exfiltrating anything, and that argument needs no policy document. It is the same reasoning a reviewer applies to an air-gapped machine, just cheaper.
For the version that goes in a security questionnaire, the three bullets are:
- Document bytes are processed in-page by WebAssembly; there is no server-side component and no endpoint to send them to.
- Model weights are static files, served from
<your origin>; they are inputs to the computation, not a channel out of it. - Verification: the pipeline runs to completion with networking disabled after first load.
What this does not buy you
Every honest privacy claim comes with a boundary, and here is this one's.
An untrusted device is still untrusted. If the user's machine has malware, or a hostile browser extension with page access, running locally does not save you. It removes your server from the threat model, not theirs.
Your own app is still in the path. The library does not upload. Your application code can. Nothing stops a well-meaning analytics call from including a filename, or an error reporter from capturing a page image in a breadcrumb. The property holds for the pipeline; it holds for your product only if you keep it.
That is worth an explicit rule in review: anything that touches a row is in scope. Error reporting, session replay, analytics, and log shipping all deserve a second look in a codebase that makes this claim.
Persistence is a decision you now own. Cached models live in IndexedDB on the user's disk. That is weights, not documents — but on a shared machine it is still a fact about what the user did, and evict() exists for the flows where that matters.
Compliance is not automatic. Not sending data to a processor removes one category of obligation. It does not remove your obligations as a controller for anything you do store server-side afterwards.
The pattern this enables
The strongest version of this is not "build a fully local product" — that is a big commitment. It is a much smaller change to an ordinary upload flow:
drop file → OCR + NER locally → show the user what's in it
→ they redact → upload the redacted version
The backend does not change at all. The document that reaches your server has already had its PII blacked out, by code running on the user's machine, before anything was transmitted. You have improved your data-handling posture without a migration.
Trade-offs
- You inherit the operational failure. No server means no server to blame, and no retry policy. A model that fails to load on someone's laptop is a UI state you have to design.
- First load is expensive. ~6 MB for OCR is nothing; ~580 MB for the default NER model is not. The privacy property costs bytes.
- "Verify it yourself" only works if it stays true. This is a property of a build. It deserves a test, not just a paragraph — a check that no network request is made during a pipeline run is cheap to write and cheap to keep.
- It does not scale. One tab, one machine. At volume, a server with a proper DPA is both cheaper and, done well, perfectly respectable.
Try it
npm install @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
Open the pipeline in the builder, run it once so the weights are cached, then turn off your network and run it again. It works. That is the demonstration, and it takes about a minute.
Further reading
- Introduction
- Self-hosting models — including the fully-offline configuration
- Models and caching
- Redact PII from a document
- Repo: StabRise/scaledp-ts



Top comments (0)