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
- Audit whether your compensation code compensates. The four-pass rotation probe was pure cost. Measurement (bit-identical outputs) beat architecture review at finding it.
- Structured content wants the NLP helpers off. Language correction and cross-level confidence comparisons both manufactured errors out of good recognitions.
- 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.
- 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.
- 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.