People OCR some of the most private things they own: ID cards, payslips, contracts, medical letters. Uploading those to a random "image to text" site never felt right, so for ThatToolSite I wanted OCR that runs entirely in the browser, with no server and not even a CDN.
Tesseract.js makes that possible. Here's what it took to make it fast, private and reliable.
Self-host everything (yes, everything)
Out of the box, Tesseract.js fetches its worker script, its WebAssembly core and the language data from a CDN. That works, but it means a third party sees every visitor who uses the tool, and the tool breaks if the CDN does. We serve all of it from our own domain under /ocr:
const worker = await createWorker(lang, 1, {
workerPath: "/ocr/worker.min.js",
corePath: corePath(), // SIMD or plain build, see below
langPath: "/ocr/lang", // tessdata_fast, gzipped
cachePath: "tts-ocr-fast-v1", // IndexedDB key, bump when data changes
workerBlobURL: false,
gzip: true,
});
Our browser test proves it: it reads an image and fails if any request goes to another origin during OCR.
Pick the right engine build for the device
Tesseract ships a SIMD build of its WebAssembly core, which is noticeably faster where the CPU and browser support it, plus a plain fallback. We detect support with a tiny WebAssembly module that uses one SIMD instruction:
const SIMD_PROBE = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123,
3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11]);
export function hasWasmSimd(): boolean {
try {
return typeof WebAssembly === "object" && WebAssembly.validate(SIMD_PROBE);
} catch {
return false;
}
}
export const corePath = (simd = hasWasmSimd()) =>
`/ocr/${simd ? "tesseract-core-simd-lstm.js" : "tesseract-core-lstm.js"}`;
Keep the download small
Sizes matter when the whole engine runs on a phone:
| File | Size |
|---|---|
| WebAssembly core (SIMD or plain) | about 2.9 MB |
| English data (tessdata_fast, gzipped) | 1.97 MB |
| Hindi, Spanish, French, German, Portuguese | 0.6 to 1.1 MB each |
We use the fast language models, not the "best" ones: they're several times smaller, and for printed text the accuracy difference is small. Language data is cached in IndexedDB, so the second visit starts instantly, and a language is only downloaded when someone picks it. None of this loads until the visitor drops an image; the page itself stays light.
Help Tesseract see the text
Most bad OCR results come from the image, not the engine. Two cheap fixes made the biggest difference:
Scale to the size Tesseract likes. It reads best when letters are roughly 20 to 40 pixels tall. Small screenshots get enlarged up to 2×, and huge phone photos are shrunk so the longest side is at most 4,000 px, which also keeps it quick.
An optional clean-up pass for faint scans and dark-mode screenshots: convert to greyscale, stretch the contrast so the darkest 1% becomes black and the lightest 1% white, and flip light-on-dark images to dark-on-light. It's a few dozen lines of pixel code with no library, and it rescues a lot of "Tesseract read nothing" cases.
Don't block the page
Recognition runs in Tesseract's own Web Worker, and we reuse one worker for every image. Calls are queued, so switching language waits for the current image instead of racing it. Progress events drive a real progress bar, and the test suite checks the page stays responsive while OCR runs.
Be honest about the limits
Tesseract is excellent on printed text and weak on handwriting, and it doesn't keep table layout. The tool says that up front rather than letting people discover it after a bad result.
How we test it
The Playwright suite generates images with known text (including a dark-mode screenshot and a Spanish sentence with accents), runs them through the tool and compares the output. It also checks the worker, core and language files come from our own server.
Try it on something private: Image to text, in your browser. It's free, and the image never leaves your device.
Top comments (0)