There is no universally best OCR engine. The right choice depends on document complexity, language coverage, privacy constraints, traffic, latency, available infrastructure, and how much of the recognition pipeline a team is prepared to maintain.
This article starts with the decision rather than the code. It compares common OCR approaches, explains which environments each one fits, and then records how ToolGarden implemented PP-OCRv5 with PaddleOCR JS and ONNX Runtime. The browser is the concrete case study, not a restriction on the conclusions: the same evaluation method applies to servers, desktop applications, mobile clients, internal systems, and public web products.
Start with requirements, not a model name
OCR quality is not one number. A model can read a clean English scan well and fail on Chinese storefront text, rotated receipts, dense tables, handwriting, or a low-light phone photo. Before selecting a stack, build a representative evaluation set and define what a useful output means: plain text, positioned blocks, tables, key-value fields, searchable PDF, or a fully reconstructed document.
Operational constraints are equally important. Ask whether images may leave the device, whether the product must work offline, how much first-load download is acceptable, which browsers or operating systems must be supported, whether GPUs are available, and who will update models and runtime binaries. These answers often eliminate more options than an accuracy demo does.
- Accuracy: languages, fonts, rotation, perspective, handwriting, tables, and small text.
- Output: plain text, coordinates, reading order, structured fields, or layout recovery.
- Operations: latency, throughput, cold start, memory, package size, and offline behavior.
- Governance: upload policy, data region, retention, auditability, and vendor dependency.
- Ownership: integration effort, model upgrades, preprocessing, post-processing, and testing.
Compare the main OCR implementation routes
The useful comparison is not simply local versus cloud. A cloud document API, a Python PaddleOCR service, Tesseract, PaddleOCR JS, and a custom ONNX pipeline expose different abstraction levels. They move cost between infrastructure, network transfer, client resources, engineering time, and vendor dependency.
This table is a selection guide, not a universal ranking. Accuracy must be measured on the same images, languages, preprocessing, and output requirements. Comparing a cloud table parser with a local plain-text recognizer answers the wrong question.
| Route | Best fit | Strengths | Costs and limits |
|---|---|---|---|
| Cloud OCR or document API | Forms, tables, IDs, receipts, rapid delivery | Managed scaling, structured extraction, little client compute | Uploads, recurring cost, latency, retention review, lock-in |
| PaddleOCR Python or native service | Servers, desktop backends, private infrastructure | Complete open pipeline, broad controls, easier GPU use | Native dependencies, service operations, larger deployment |
| PaddleOCR JS with PP-OCRv5 | Web, Electron, offline-first, no-upload products | Local inference, reusable pipeline, static delivery | WASM speed, model download, browser memory and compatibility |
| Tesseract or Tesseract.js | Clean scans, simple layouts, established language packs | Mature ecosystem and predictable classic workflow | Usually weaker on scene text and complex layouts |
| Custom ONNX pipeline | Special models, hardware, or strict control | Maximum control over tensors, batching, and output | Highest pre/post-processing and compatibility maintenance |
Choose by environment and product boundary
When uploads are acceptable and structured tables or form semantics are required immediately, a managed document API is often the shortest path. When data must remain inside controlled infrastructure and GPU throughput matters, PaddleOCR Python or another native stack is usually a better server foundation than forcing a browser runtime onto the backend.
Electron can reuse JavaScript and WASM, while a desktop sidecar can run Python or C++. Mobile products should benchmark native runtimes and device acceleration. Tesseract remains reasonable for clean, predictable scans. A custom ONNX pipeline is justified only when the extra control solves a measured problem.
ToolGarden chose PaddleOCR JS with PP-OCRv5 because its boundary was explicit: static hosting, no image upload, no OCR backend, multilingual printed text, and an acceptable one-time model download. Under different constraints, the recommendation changes.
Implementation path: establish a baseline first
Build a small golden set before writing adapters. Include screenshots, phone photos, simplified and traditional Chinese, English, Japanese, small type, rotation, low contrast, and at least one layout the product does not promise to preserve. Record expected text and critical fields rather than relying on visual impression.
Measure cold initialization, warm recognition, peak memory, model transfer size, detected blocks, and character or field accuracy. Run every candidate on the same originals. This is the only fair way to compare a website or model because preprocessing, detection, decoding, and reading order can matter as much as archive size.
| Measurement | Why it matters | Test condition |
|---|---|---|
| Cold start | Includes runtime, download, extraction, and sessions | Fresh cache and slow network |
| Warm latency | Represents repeated use | Second and later images |
| Recognition quality | Shows substitutions, omissions, and ordering errors | Each language and image category |
| Resource use | Reveals mobile instability | Large image and many text boxes |
| Failure recovery | Confirms retry really works | Offline, corrupt asset, terminated Worker |
Implementation path: isolate expensive work and define a protocol
The page owns UI state, validation, progress, and localized errors. A module Worker owns decoding, OpenCV preprocessing, model initialization, inference, and result conversion. Every message carries an ID so stale progress cannot settle a newer request.
The file becomes a transferable ArrayBuffer, moving ownership instead of cloning a large image. Temporary listeners are removed on completion. A crashed or timed-out Worker is terminated and uncached so retry starts from clean WASM state.
const worker = new Worker(
new URL('../workers/ocr-accurate.worker.ts', import.meta.url),
{ type: 'module' },
);
const data = await file.arrayBuffer();
worker.postMessage({
id: requestId,
type: 'recognize',
file: { data, type: file.type, name: file.name, size: file.size },
language,
}, [data]);Implementation path: adapt decoding to the runtime
A Worker provides Blob, createImageBitmap, ImageData, and OffscreenCanvas, but not document or HTML element constructors. Some image dependencies still check those globals. That difference caused document is not defined and HTMLImageElement is not defined.
The adapter installs only the globals the dependency reads. Canvas maps to OffscreenCanvas; sourceToMat decodes a Blob, draws it offscreen, reads ImageData, and creates an OpenCV Mat. Disposal deletes the Mat and closes the bitmap. Node, Electron main processes, and native applications should replace this adapter with their own decoder rather than expanding the shim.
function installWorkerCanvasDomShim() {
Object.defineProperty(globalThis, 'HTMLCanvasElement', {
configurable: true,
value: OffscreenCanvas,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement(tagName: string) {
if (tagName.toLowerCase() !== 'canvas') throw new Error('Unsupported element');
return new OffscreenCanvas(1, 1);
},
},
});
}Implementation path: load PP-OCRv5 as one versioned unit
Exact detection and recognition assets are passed to PaddleOCR JS instead of relying on remote defaults. Languages map to Paddle identifiers and initialized OCR instances are cached by language. Detection and recognition batch sizes can be tuned separately.
ONNX Runtime uses WASM with SIMD, no proxy, and one thread. One thread avoids cross-origin-isolation requirements. The shown side limit and thresholds are product tuning values, not universal constants; tiny text, scene images, and mobile memory limits need their own benchmark.
const ocr = await PaddleOCR.create({
lang: toPaddleLanguage(language),
ocrVersion: 'PP-OCRv5',
textDetectionModelName: 'PP-OCRv5_mobile_det',
textDetectionModelAsset: {
url: '/models/paddleocr/ppocr-v5/PP-OCRv5_mobile_det_onnx_infer.tar',
},
textRecognitionModelName: 'PP-OCRv5_mobile_rec',
textRecognitionModelAsset: {
url: '/models/paddleocr/ppocr-v5/PP-OCRv5_mobile_rec_onnx_infer.tar',
},
sourceToMat: sourceToMatInWorker,
ortOptions: {
backend: 'wasm',
wasmPaths: {
mjs: '/models/paddleocr/onnxruntime-web/ort-wasm-simd-threaded.mjs',
wasm: '/models/paddleocr/onnxruntime-web/ort-wasm-simd-threaded.wasm',
},
numThreads: 1,
simd: true,
proxy: false,
},
});
const [result] = await ocr.predict(image, {
textDetLimitSideLen: 1280,
textDetThresh: 0.24,
textDetBoxThresh: 0.34,
textDetUnclipRatio: 1.8,
textRecScoreThresh: 0.28,
});Implementation path: own the result contract
Library output is converted into an application-owned discriminated union. Success contains text, blocks, confidence, boxes, source dimensions, and duration. Failures use stable codes such as model_load_failed, worker_timeout, recognition_failed, and no_text_detected, so UI copy never parses exceptions.
Polygons become display boxes, weak items are filtered, and blocks are grouped into rows by vertical center and average height. Rows sort top to bottom and blocks left to right. This provides useful plain text, but it does not claim to reconstruct tables, columns, or original document styling.
Important details: liveness, delivery, caching, and memory
A fixed timeout confuses slow progress with failure. The Worker sends a heartbeat every ten seconds around initialization and prediction. The page resets an inactivity timer on each matching message and maintains separate hard limits for model and processing phases. Every failure path discards the Worker, so retry is defined.
Self-hosted models remove third-party CORS and availability risk, but URLs, MIME types, cache updates, file limits, and first-load UX remain. ONNX Runtime JavaScript and WASM must come from the same release. OpenCV Mats, ImageBitmaps, URLs, listeners, timers, rejected initialization Promises, and Service Worker caches all require explicit lifecycle management.
async function withProgressHeartbeat(progress, operation) {
postProgress(progress);
const heartbeat = setInterval(() => postProgress(progress), 10_000);
try {
return await operation();
} finally {
clearInterval(heartbeat);
}
}
const IDLE_TIMEOUT = 180_000;
const MODEL_PHASE_TIMEOUT = 12 * 60_000;
const PROCESSING_PHASE_TIMEOUT = 6 * 60_000;
function failTimeout() {
worker.terminate();
cachedWorker = null;
resolve({ ok: false, code: 'worker_timeout' });
}- Pin package and binary versions together and inspect the final bundle.
- Serve models, MJS, and WASM with stable URLs and correct content types.
- Cache successful initialization but remove rejected Promises.
- Limit image pixels before canvas allocation and avoid default concurrency.
- Version Service Worker caches from built content, not a handwritten constant.
Problems encountered and what they actually meant
Failures occurred at installation, bundling, asset delivery, Worker compatibility, runtime ABI, and caching layers. Treating every message as an isolated npm issue caused rework. The useful debugging move was to identify the layer before changing dependencies.
The final timeout was deceptive. A longer timer could never fix an incompatible runtime. A deterministic image with known text, sent through the production Worker and public asset paths, exposed the hidden _OrtGetInputName failure.
| Symptom | Root cause | Durable fix |
|---|---|---|
| npm edgesOut failure | Installer dependency-tree failure | Use exact compatible dependencies and reproducible install mode |
| Cannot resolve ort.bundle.min.mjs | Expected runtime entry was unavailable | Alias a real browser entry from the selected release |
| No matching ORT version | Requested release did not exist | Verify the registry and pin a published version |
| WASM exceeded file limit | Wrong runtime variant entered output | Ship only required files and check build sizes |
| Dynamic MJS fetch failed | URL or module deployment was wrong | Use same-origin explicit URLs and verify responses |
| document or HTMLImageElement missing | DOM code ran inside a Worker | Provide a narrow OffscreenCanvas adapter |
| _OrtGetInputName missing | JavaScript and WASM ABIs differed | Vendor and checksum one matching runtime set |
| Worker timed out | Fixed timer hid initialization failure | Expose layer errors and use heartbeat liveness |
Verify the complete path, not only the build
Type checking cannot prove that archives download, MJS locates WASM, or a Worker decodes an image. Verification must use production public paths, cache behavior, Worker entry, and OCR API. Start with a generated image containing known text, then use the golden set for quality.
Test cold and warm caches, slow network, offline-after-cache, rotation, languages, large images, forced Worker termination, and retry. Validate deployments on a fresh origin or after clearing the Service Worker. Build-time SHA-256 checks prevent missing or mixed assets from reaching users.
for (const asset of pinnedOcrAssets) {
const actual = createHash('sha256')
.update(readFileSync(asset.path))
.digest('hex');
if (actual !== asset.sha256) {
throw new Error(`OCR asset checksum mismatch: ${asset.path}`);
}
}Summary
Choose OCR by task and operating boundary, not by model size or one demo. Cloud document APIs fit managed structured extraction. Native PaddleOCR fits controlled servers and GPU workloads. Tesseract remains useful for predictable scans. PaddleOCR JS with PP-OCRv5 fits local web delivery. In every route, preprocessing, runtime compatibility, result contracts, liveness, caching, memory, and end-to-end verification are parts of the OCR system. That decision trail is more reusable than any single code sample.