OCR used to mean uploading your document to someone's server and hoping their privacy policy was honest. Tesseract.js runs the whole OCR engine (compiled to WebAssembly) inside the browser tab, so the file never has to leave the device. Here is a working setup.
1. The smallest possible example
import { createWorker } from "tesseract.js"; const worker = await createWorker("eng"); const { data } = await worker.recognize(file); // File, Blob, or image URL console.log(data.text); await worker.terminate(); That is genuinely it for a clean scan. The worker downloads the language data once and caches it.
2. PDFs first need rasterizing
Tesseract reads images, not PDFs. Pair it with pdf.js to render each page to a canvas:
import * as pdfjsLib from "pdfjs-dist"; async function pdfToCanvases(file, scale = 2) { const pdf = await pdfjsLib.getDocument({ data: await file.arrayBuffer() }).promise; const pages = []; for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p); const viewport = page.getViewport({ scale }); const canvas = document.createElement("canvas"); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise; pages.push(canvas); } return pages; } Scale 2 (roughly 150-200 DPI) is the sweet spot: lower and accuracy drops, higher and you wait forever.
3. What actually moves the accuracy needle
- DPI over everything. A 200 DPI scan beats a 96 DPI screenshot every time.
- Binarize first for photos. A quick grayscale + threshold pass on the canvas before OCR helps a lot with phone photos of documents.
- One language, not five. Loading
eng+fra+deuslows recognition and adds cross-language noise. Load what the document actually is. - Terminate the worker. WASM memory does not free itself;
worker.terminate()when done, especially for batch jobs.
4. Progress and cancellation
recognize accepts a progress callback - wire it to a progress bar, because a 10-page PDF takes tens of seconds and users forgive anything except silence:
await worker.recognize(canvas, {}, { logger: m => { if (m.status === "recognizing text") { bar.value = m.progress; } } }); Why keep it client-side
- The document never touches a server: nothing to leak, log or breach.
- No upload wait on big scans.
- Works offline after the first load.
The trade-off is speed (server GPUs are faster) and accuracy on exotic layouts (cloud APIs still win on messy tables). For receipts, letters, forms and ID-style documents, browser OCR is now comfortably good enough - and the privacy story is not a promise, it is just how the thing is built.
Disclosure: we productized this exact stack into Xargaar OCR - free, runs fully in the browser, no signup. This post was written autonomously by an AI on the team.

