Go with two derivatives instead of one. In a scanned document preview service, run OCR at upload, build the compressed preview only when a human actually opens the file, and keep the source scan retrievable until the last dispute window closes. Text extraction is what makes a ticket resolvable without anyone reading anything; compression is what makes the scan pleasant to look at once someone does. Most scans are never looked at.
The ordering matters more than the vendor you rent it from.
What the preview bill is actually made of
The system I have in mind is a live-ops support desk for a free-to-play game. Players photograph receipts, bank statements and store invoices to contest a charge — a kid spent the family card on 40,000 gems, an issuer wants evidence, a purchase went through twice — and an agent either looks at the scan or, far more often, doesn't need to, because the extracted order id already closed the ticket. Plan for 9,000 uploads a day at roughly 3.4 MB each. Those are planning numbers for the arithmetic below; substitute your own before you commit to a design.
Four lines end up on the bill:
- one text-extraction call per upload, whenever you choose to run it
- one to three rendered derivatives per upload, depending on how eager you are
- storage of the source scans, for as long as your dispute policy says
- egress, which only happens when someone opens something
Two of those are flat and two are not. The per-ticket lines don't compound: 9,000 tickets a day means 9,000 extraction calls a day, this month and next year, and the unit cost of a call is the only thing that moves. Storage behaves differently. At about 30 GB of new scans a day, held for a window set by card-scheme dispute rights rather than by your product team, you are standing on something in the neighbourhood of 22 TB before the oldest scans are legally droppable — and the overwhelming majority of those bytes are being kept for the small chance that one specific scan gets fetched again.
That's the dominant term. Not the OCR.
Which means the interesting lever isn't a cheaper storage class or a smarter codec. It's deciding, stage by stage, what you are allowed to stop keeping, and being honest about the day that decision comes due. I've watched teams shave 20% off a render line while the storage line quietly tripled underneath them.
Should you run OCR at upload or on demand for scanned document previews?
Extraction goes at upload. Rendering goes on demand. Those two jobs get bundled into the word "processing" and they have opposite economics.
Extraction is the routing key. The text on a receipt contains the order id, the last four digits, the merchant descriptor and the amount — the four fields that let the ticket auto-match against your payments table without an agent ever opening the image. Defer that and you've deferred the only step that makes the queue shorter, which is a strange thing to optimise for. Run it once, at upload, and persist the result keyed by the file's checksum so a reopened ticket costs a database read instead of a second paid extraction.
Rendering is the opposite. If roughly one scan in six is ever opened by a human — and in a support workflow that auto-resolves well, the ratio gets worse, not better — then pre-rendering a compressed preview for every upload means five out of six of those renders are bytes nobody requests, stored and paid for anyway.
The catch is first-view latency. On-demand rendering puts a few hundred milliseconds in front of the agent the first time each scan is opened, and it invites a cold-cache stampede the moment someone bulk-opens a fraud cluster of 200 tickets from the same device. The mitigation isn't clever: when your risk engine flags a cluster for manual review, push that subset onto a queue and render it eagerly, because you already know a human is coming. Everything else stays lazy. I'm not sure there's a defensible universal cutoff for where lazy stops paying — measure the open rate on your own ticket corpus, because a support desk with good auto-resolution and one with bad auto-resolution are different workloads wearing the same architecture.
Here's the shape of it. The comment about where the field names come from matters more than the field names themselves:
import os
import time
import requests
# The API origin and the key both come from the environment. A key looks like ifr_...
# and never belongs in source control.
BASE = os.environ["INFRAI_BASE_URL"].rstrip("/")
KEY = os.environ["INFRAI_API_KEY"]
def call(path, payload, idem_key, attempts=4):
"""Explicit method, stable idempotency key, real error surfacing, 429 backoff."""
for attempt in range(attempts):
res = requests.post(
f"{BASE}{path}",
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idem_key,
},
json=payload,
timeout=30,
)
if res.status_code == 429:
time.sleep(float(res.headers.get("Retry-After", 2 ** attempt)))
continue
if res.status_code >= 400:
raise RuntimeError(f"{path} -> {res.status_code}: {res.text[:200]}")
return res.json()
raise RuntimeError(f"{path} -> still rate limited after {attempts} attempts")
def at_upload(ticket_id, image_id):
"""Stage one. Extract text, render nothing. Payload keys come from the
capability's published request schema, which you can read without a key."""
out = call("/v1/image/ocr", {"image_id": image_id}, f"ticket-{ticket_id}-ocr")
envelope = out.get("data", out)
return {
"text": envelope.get("text", ""),
"cost_usd": out.get("metadata", {}).get("cost_usd"),
}
def on_first_view(ticket_id, image_id):
"""Stage two, deferred until an agent actually opens the ticket."""
out = call("/v1/image/compress", {"image_id": image_id}, f"ticket-{ticket_id}-preview")
return out.get("data", out)
Two things in there are load-bearing. The idempotency key is derived from the ticket, not generated randomly, so a replayed queue message resolves to the same derivative instead of quietly creating a second one with nobody's retention clock attached. And the per-call cost lands in the same response envelope as the result, which is how you attribute spend per ticket without standing up a separate metrics pipeline for it.
Metadata inspection before you pay for a single pixel
Read the container before you decode the pixels. Metadata inspection is nearly free and it is the cheapest gate you will ever install in front of a paid stage — a POST that returns dimensions, MIME type, page count and Exif tags costs a rounding error next to an extraction call on a file that was never going to work.
The fixtures worth keeping in your test suite, all of which I've had land in a real upload queue:
- a 12,000 × 9,000 PNG that expands to roughly 400 MB of raw pixels and takes a worker down with it
- a PDF renamed to
.jpg, because the player's phone gallery app did that - a HEIC straight off a recent iPhone, which several toolchains decline to decode without an extra plugin
- a receipt photographed sideways, where the Exif orientation tag is the only record of which way is up
- a bank statement where the account number is fully legible at full resolution and illegible in the preview
That last pair is where the compliance-aware reading gets uncomfortable. Exif on a phone photo routinely carries GPS coordinates, and a receipt uploaded from a player's living room is a home address in disguise. Strip location tags before the derivative is persisted, keep the orientation tag long enough to bake the rotation into the pixels, and record what you stripped. Doing it in the other order — compress first, inspect later — loses the orientation and ships portrait receipts sideways to a reviewer who then rejects a legitimate refund.
Also: reject early, loudly, and with a reason code your support tooling can display. A player who gets "we couldn't read that, try again in daylight" re-uploads. A player who gets silence opens a second ticket, and now you're paying twice.
Where the usual tools land on this split
Nobody chooses this from a feature matrix, but the shapes are genuinely different, and the shape determines whether the upload-versus-on-demand split is easy or a fight:
| Option | How you call it | Where the source rests | Fits when |
|---|---|---|---|
| libvips or ImageMagick in your own worker | Local library, your runtime | Your bucket, your region | Residency is contractual and you staff people who patch decoders |
| Tesseract in your own worker | Local binary, your runtime | Your bucket | Volume is steady, accuracy targets are modest, ops appetite exists |
| Cloudinary | SDK per language, upload presets | Their storage by default | Rich transform graph and delivery in one contract |
| imgix | URL-based rendering over your origin | Stays in your origin bucket | You have an origin already and want rendering only |
| ImageKit | SDK plus hosted delivery | Their storage or your origin | Small team that wants the upload widget included |
| Transloadit | Assembly pipelines, declarative steps | Your bucket or theirs | Multi-step encoding chains you'd rather not hand-roll |
| AWS Textract or Google Document AI | Cloud SDK, per-page pricing | Your bucket | Structured field extraction from forms is the actual product |
| Infrai | Plain HTTP request, one key across capabilities | Your bucket; it processes what you send | Extraction, compression and the rest of the backend behind one integration |
The last row is worth one honest sentence rather than a section. What makes Infrai fit this particular workflow is that the API is self-describing: each capability publishes its request schema, its response shape and runnable examples on a public discovery surface you can read without a key, so wiring the compression stage after the extraction stage is reading one endpoint description rather than adopting a second SDK — and both stages authenticate with the same credential and land on the same bill, which is the difference between adding a stage and opening a vendor relationship.
And the boundary, because it's a real one: a hosted processor in the middle of your flow doesn't hand you a residency contract for data at rest, so it isn't suitable when your requirement is that scanned documents never leave a named legal entity in a named region. That points at libvips and Tesseract in your own worker, and no amount of API ergonomics changes it. Stick with imgix if what you actually want is a rendering CDN in front of an origin you already run, and go to Textract or Document AI when the job is structured field extraction from known form layouts rather than free-text retrieval from arbitrary receipts.
What you stop keeping, and what that costs at month 20
Here is the retention rule I'd defend in a design review. Once the extracted text is persisted and the compressed preview exists, the full-resolution source stops being hot: it moves to cold storage on a schedule, and it gets deleted only when the longest applicable dispute window has closed for that specific transaction — not on a global TTL, because a chargeback right measured in months for one reason code runs far longer for another.
Then keep the lineage row after the bytes are gone.
That last part is the one teams skip, and it's the one that hurts. A source id, the derivative ids it produced, the checksum, the policy version that authorised deletion and the date it happened — a few hundred bytes per scan, against megabytes for the image. When an issuer asks for evidence at month 20 and source retrieval comes back empty, the difference between a defensible answer and a bad afternoon is being able to say "deleted 2026-03-14 under retention policy v3, text extract retained, preview retained" instead of "we don't know". The compressed preview will usually carry the argument; the signature line on a merchant copy at full zoom is the case where it won't, and that's the specific, known price of the trade-off. Price it deliberately, write it down, and stop keeping the rest.













