The Best OCR Fix Was Deleting Code: Lessons from a React Native Receipt Scanner

Receipts are the most hostile documents in consumer computing. They're printed on thermal paper that fades in a glovebox, crumpled before they leave the store, photographed at 11 PM under a single kitchen bulb, and shaped like nothing else your image pipeline has met — a "tall" receipt can push past a 20:1 aspect ratio, which is less a photo and more a scroll.

I spent this spring building a React Native library for scanning them: a New-Architecture TurboModule wrapping ML Kit on Android and VisionKit + Vision on iOS — capture, perspective crop, JPEG compression, EXIF, and on-device Korean-first OCR. Seven release series in, the pattern I keep noticing is that almost every real quality win was subtractive. The biggest one was deleting a feature I was proud of.

Here's the tour, in the order the receipts taught me.

Ground rule: natives return primitives, and nothing else

One decision predates all the OCR lessons and made them survivable: the native modules return only primitives — JPEG file:// URIs, EXIF dictionaries, raw OCR text. No parsed totals, no merchant names, no upload helpers, no cloud calls. Parsing PRs get rejected on sight, with a standing three-question test for whether a feature belongs in the native layer or in the caller's app.

This sounds like architectural piety, but it's actually laziness with good PR (the best kind): every lesson below required changing OCR behavior without renegotiating a parsing contract, because there wasn't one. Small API surfaces are what make aggressive iteration cheap.

Lesson 1: reject garbage before you process it

Users photograph the table, the cat, and occasionally a receipt. If you accept every capture, the junk flows downstream to whatever expensive thing comes next — in most receipt products, a cloud parsing step that bills you per cat.

So the library grew an OCR floor: after on-device OCR, a capture whose text is too sparse to plausibly be a receipt gets status: "rejected" instead of a result. The current gates are deliberately dumb — a minimum text length of 12 characters and at least 2 lines, both provisional numbers — because the goal isn't classification, it's refusing the obvious. Notably, the floor lives in the JS wrapper, not the native code: it's a policy, and policies belong where the app can see them (ocrFloor: false turns it off).

One confession for the changelog readers: ocr: false disables OCR — and with it, silently, both the reject-filter and OCR-driven auto-rotation. That coupling is documented, but "documented" and "kind" are different words.

Lesson 2: the rotation feature I deleted

Receipts arrive rotated, so OCR needs upright text. My first solution was thorough in the way that should have been a warning sign: run OCR four times, once per 90° rotation hint, and keep the orientation that scores best. It worked. It also cost 150–450 ms per extra probe on real hardware, which on a mid-range phone turns "scan" into "scan… hold on."

Then, while auditing the metrics on a Galaxy Z Flip6, I noticed the four probes returning bit-identical results at 0°, 90°, 180°, and 270°. Not similar — identical. On the device I tested, ML Kit's Korean recognizer is rotation-invariant: it finds the text whichever way the bitmap is turned. My elaborate probe was asking a question the recognizer had already answered four times.

The replacement shipped in v0.4.0: one OCR pass, then compare the image's aspect ratio against the recognized lines' aspect ratio to decide whether the photo needs turning (a wide-image/narrow-lines mismatch gates the correction; sub-0.7 line aspect, provisional as ever). Faster, simpler, and the accuracy didn't move — because the deleted code was never adding any.

I want to be precise about scope, because this is a one-device observation about one recognizer model: I'm not telling you ML Kit is rotation-invariant. I'm telling you to check whether your expensive compensation layer compensates for anything. Mine didn't.

Lesson 3: turn off the helpful features

On iOS, Vision's text recognizer ships with usesLanguageCorrection enabled by default — a dictionary-based cleanup that nudges recognized text toward real words. Lovely for prose. Catastrophic for receipts, which are largely not words: prices, product codes, abbreviated SKUs, tax lines. The corrector would look at a perfectly recognized code and "fix" it into vocabulary.

v0.4.5 turned it off, and I'll generalize this one with confidence: for structured content, disable the post-processing designed for natural language. OCR engines are tuned for the median document, and a receipt is nobody's median. The same release stopped comparing confidence scores across Vision's .fast and .accurate recognition levels to pick a winner — those scores are calibrated within a level, not across levels, so the comparison was a category error producing plausible-looking nonsense. Routing now uses something with actual cross-level meaning: the count of non-empty lines. Confidence still gets logged, demoted from judge to witness.

Lesson 4: a backstop is not a classifier

Perspective-crop needs a document quadrilateral, and quad detection sometimes hallucinates — a wild trapezoid latched onto a shadow. The tempting fix is a "good quad" classifier with thresholds tuned until the demo works. The shipped fix (v0.6.0) is humbler: reject only the egregious tail — opposite-edge ratios beyond 2.2, degenerate self-intersecting quads — and soft-fail to a plain bounding-box crop instead of erroring. An earlier, stricter guard got removed after its only measurable achievement turned out to be flagging legitimately long receipts. (The 20:1 scrolls again. They find every threshold eventually.)

Naming matters here: the code calls it a backstop, and that word did real design work — a backstop that starts rejecting borderline-fine inputs has failed at its one job, which is to be embarrassing to trip.

Lesson 5: your Android baseline includes Google Play Services, whether you knew it or not

The capture UI wraps ML Kit's Document Scanner, which is not a library you bundle — it's a Play-Services-backed dynamic module that downloads its model on first use. The dependency chain means: no Google Play Services, no scanner. AOSP builds, GrapheneOS and friends, Amazon Fire devices, post-2019 Huawei — none of them can run that code path at all, and a plain-AOSP emulator image will fail QA in ways a google_apis image won't.

The part that actually bit me is subtler: there's no upfront availability pre-check, so on a Play-less device the failure arrives at scan time, as a promise rejection. Meanwhile — and this asymmetry is worth writing on a whiteboard — the Korean text recognition model is a bundled ML Kit artifact needing no Play Services at all. Same "ML Kit" brand, opposite deployment realities. Read the artifact names, not the marketing name.

Lessons learned

  1. Audit whether your compensation code compensates. The four-pass rotation probe was pure cost. Measurement (bit-identical outputs) beat architecture review at finding it.
  2. Structured content wants the NLP helpers off. Language correction and cross-level confidence comparisons both manufactured errors out of good recognitions.
  3. Guard the tail, don't classify the middle. Backstop thresholds should catch the absurd and wave through everything else — receipts are too weird for a tight "normal" band.
  4. Policy in the wrapper, primitives in the native. Every one of these changes shipped without breaking a caller, because the native contract never promised interpretation.
  5. Log counts, not content. The pipeline's always-on diagnostics record line counts, aspect ratios, and text length — enough to debug OCR routing from a bug report, with zero receipt contents (which are somebody's groceries, and none of my business).

The library ships on npm as react-native-receipt-scanner (0.6.0 as of July 2026). If you've fought OCR on structured documents and lost differently than I did, the comments are open. I collect failure modes the way the scanner collects aspect ratios: involuntarily, and in bulk.