DEV Community

Mykola Melnyk
Mykola Melnyk

Posted on

Your OCR pipeline probably uploads the document. It doesn't have to

Your OCR pipeline probably uploads the document

Written 2026-09-03. Code examples target @stabrise/scaledp@0.1.1. No prior OCR or ONNX knowledge assumed.

TL;DR: Every mainstream OCR API has the same architecture — your document goes over the wire to someone else's disk. Browser-native inference removes that box entirely: @stabrise/scaledp runs PDF rendering, text detection, OCR and entity recognition on onnxruntime-web, in the tab, with no upload. That is a real win for sensitive documents and a real cost in first-load bytes. This post is about both.

The architecture every OCR service has

Draw the data flow of any hosted document API and you get three boxes:

[your app] --HTTPS--> [their API] --> [their storage + GPUs]
Enter fullscreen mode Exit fullscreen mode

The third box is the one that generates paperwork. It is why OCR procurement involves a data processing agreement, a retention policy, a sub-processor list, and a conversation with whoever owns compliance. The engineering is five lines; the approval is five weeks.

That box also has a price. Hosted OCR is billed per page — typically somewhere between $0.50 and $1.50 per thousand pages, plus egress. For a product that OCRs every uploaded file, that is a line item that grows exactly as fast as your usage does.

Hosted OCR routes the document through a third party; browser-native inference has no third box in the path

None of this is a criticism of hosted APIs. For 10,000 pages a night they are the correct answer, and I say so in the trade-offs below. The point is narrower: the third box is not a law of physics. The models are small enough to run in a browser, and onnxruntime-web is good enough to run them.

The same pipeline, without the third box

[your app] --> [WASM/WebGPU in the tab]
Enter fullscreen mode Exit fullscreen mode

Here is a complete, runnable pipeline. It renders a PDF, reads it with PaddleOCR, and pulls out entities — and it makes no request to any server of ours, because there isn't one.

import { Pipeline, configure } from '@stabrise/scaledp'
import { PdfToImage } from '@stabrise/scaledp/pdf'
import { PaddleTextRecognizer } from '@stabrise/scaledp/ocr'
import { GlinerNer } from '@stabrise/scaledp/ner'

configure({
  cache: 'indexeddb',
  pdf: { workerSrc: '/pdf.worker.min.mjs' },
  onProgress: ({ file, loaded, total }) => {
    console.log(`${file}: ${Math.round((loaded / total) * 100)}%`)
  },
})

const pipeline = new Pipeline([
  new PdfToImage({ resolution: 300 }),
  new PaddleTextRecognizer({ keepFormatting: true }),
  new GlinerNer({ labels: ['person', 'organization', 'email', 'phone'] }),
])

const rows = await pipeline.transform(file)

for (const row of rows) {
  console.log(`page ${row.page}:`, row.text.text.slice(0, 80))
  for (const entity of row.ner.entities) {
    console.log(' ', entity.entity_group, '=', entity.word)
  }
}
Enter fullscreen mode Exit fullscreen mode
page 1: INVOICE 2024-0417  Acme Industries GmbH  Rechnungsdatum 14.03.2024 …
  organization = Acme Industries GmbH
  person = Marta Feldmann
  email = accounts@acme-industries.de
Enter fullscreen mode Exit fullscreen mode

One row per page. Each stage reads a named field on the row and writes another one — PdfToImage writes image, PaddleTextRecognizer reads it and writes text, GlinerNer reads that and writes ner. That is the whole composition model, and it is the same one the Python ScaleDP library uses on Spark.

What actually disappears

The DPA. Not "is easier to get" — is not needed for this data flow. There is no processor, because there is no third party in the path.

The retention question. "How long do you keep it?" has an answer that requires no policy document: the bytes were in a tab, and the tab closed.

Egress and per-page cost. After the first load, the marginal cost of a page is CPU time on a machine you are not paying for.

Round-trip latency on re-runs. This is the underrated one. Changing a threshold and re-running is 200 ms, not a network round trip, which changes what kind of UI you can build. An interactive parameter panel over a hosted API is a rate-limiting problem; over a local pipeline it is just a re-render.

What appears instead

Bytes on first load. PaddleOCR's default preset (v6-small) is about 6 MB of weights — genuinely small. The default GLiNER NER model is ~580 MB. That is not a rounding error, and it is the single biggest objection to this whole approach. Mitigations exist — smaller models (gliner-pii-edge is ~181 MB), IndexedDB caching so it downloads once, self-hosting the weights from your own origin — but the first visit pays.

OCR weights are about 6 MB; the default zero-shot NER model is about 580 MB

A device you do not control. Your server has known cores and known memory. Your user's machine has neither. WebGPU may be available or may not; threads need response headers your host may not send. The library reports what it actually got rather than assuming:

import { isWebGpuAvailable, isCrossOriginIsolated } from '@stabrise/scaledp/ocr'

console.log({
  webgpu: await isWebGpuAvailable(),   // false on plenty of real machines
  threads: isCrossOriginIsolated(),    // false unless COOP/COEP are set
})
Enter fullscreen mode Exit fullscreen mode

Throughput. One tab is one machine. There is no horizontal scaling story here beyond "open more tabs", and that is not a story.

Trade-offs

  • At volume, a server wins and it is not close. If you are processing 10,000 pages a night, the per-page cost of a GPU you already own beats the per-page cost of downloading 580 MB to ten thousand browsers. Use the Python sibling; the pipeline code ports almost mechanically.
  • The 580 MB is real and unavoidable if you want good multilingual NER. You can drop to a 181 MB model, drop NER entirely and keep OCR at 6 MB, or self-host from a fast origin — but you cannot make a good zero-shot NER model small.
  • First run is much slower than the tenth. Model download dominates the first pipeline run completely. Design the UI around that (onProgress, a cache check before you start) rather than hoping users are patient.
  • AGPL-3.0-or-later. Same licence as the Python library. If that does not work for a closed-source product, StabRise does commercial licensing. Worth knowing before you build on it, not after.

Try it

npm install @stabrise/scaledp onnxruntime-web ppu-paddle-ocr pdfjs-dist
Enter fullscreen mode Exit fullscreen mode

The engines are optional peer dependencies — install only the ones your pipeline uses. Importing the core pulls in no ML runtime at all.

Drop your own PDF into the builder at scaledp-ts.stabrise.com/demo, pick a detector and a recognizer, and watch the network panel while it runs. After the models are cached you can turn the wifi off and run it again.

Further reading

Top comments (0)