Short answer: use a presigned object-storage upload when your browser can use the bucket's CORS policy; otherwise send the file through your backend, then resize and retain the original there. For customer-support receipts, that choice matters more than the thumbnail library because the original is the audit record and large files dominate throughput.
Start with the retention bill, not the thumbnail
A receipt workflow has two different objects: the immutable-ish original that an auditor may ask for, and derived thumbnails that agents need to scan quickly. The original travels once at upload time, while every thumbnail request can be served repeatedly. Keep those paths separate and give each object a predictable key such as tickets/1842/original.jpg and tickets/1842/thumb-320.jpg.
The expensive term is usually bytes moved and retained, not the few milliseconds spent calculating a resize. Measure upload size, retry volume, and retention duration before choosing a provider. A 25 MB phone photo retried three times is 100 MB of ingress work before any thumbnail exists. That is the number I would put on the first dashboard.
There is a catch: retention is a product decision disguised as storage configuration. If you discard the original after generating a 320-pixel preview, a later dispute may have no evidence. If you keep every intermediate resize forever, cleanup and privacy requests become harder. I keep the original for the audit period, version the thumbnail names, and delete abandoned multipart parts explicitly.
Keep it boring.
How should browser uploads, presigned URLs, and backend thumbnails fit together?
The normal sequence is deliberately boring. The backend authenticates the support agent, creates a short-lived presigned upload URL for a private key, and returns that URL to the browser. The browser uploads the bytes directly. A completion event or an authenticated callback tells the backend which key arrived; the backend fetches the original, validates its type and dimensions, writes thumbnail variants, and records those keys with the ticket.
Presigned download URLs use the same boundary. Do not put a permanent object URL in an email or a support-page HTML attribute. Issue a download URL when an authorized agent asks for the receipt, and set a cache policy that matches the URL lifetime. MDN's Cache-Control guidance is useful here because a private response cached for too long is a data-leak-shaped bug, even when the object store itself is private.
Direct upload only works if the browser's origin is allowed by the bucket's CORS rules. Some platforms expose an independent CORS editor; others do not. In the latter case, proxy the upload through your backend. That adds a hop and backend bandwidth, but it gives you a place to enforce content limits, inspect headers, and return a consistent error to the UI. Your mileage may vary with corporate proxies and mobile networks.
For a large receipt or a batch import, multipart upload is the throughput tool. Create an upload, presign each part, upload parts in bounded parallelism, and complete it with the recorded part numbers and checksums. A worker timeout can leave parts behind, so the cancellation path must abort the upload; lifecycle rules with a one-day minimum are not a substitute for that application cleanup.
Here is the small part of the flow I would test first. The API base is injected so the same client can point at a staging gateway or the production service without changing code. The route and method are explicit, and a 429 response respects Retry-After before trying again.
import os
import time
import requests
api_base = os.environ["INFRAI_API_BASE"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
bucket = "support-receipts"
object_key = "tickets/1842/original.jpg"
idempotency_key = f"presign-{bucket}-{object_key}"
for attempt in range(4):
response = requests.post(
f"{api_base}/v1/storage/object/presign/{bucket}/{object_key}",
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": idempotency_key,
},
json={"method": "PUT", "expires_in": 900},
timeout=20,
)
if response.status_code != 429:
response.raise_for_status()
upload_url = response.json()["url"]
break
delay = int(response.headers.get("Retry-After", "2"))
time.sleep(delay * (2 ** attempt))
else:
raise RuntimeError("presign rate limit did not clear")
print(upload_url)
The returned URL is the browser's upload target; the backend still owns authorization and the later resize job. For multipart files, use the corresponding create, part-presign, complete, and abort operations as one state machine, rather than treating completion as optional.
The state machine is where most production detail hides. On an upload request, I first reserve a ticket-scoped key and write a row containing the expected content length and a digest supplied by the client. The presign response goes to the browser, but the row remains pending until the browser reports success and the backend checks the object head. A worker then downloads the original into a bounded temporary stream, rejects dimensions outside policy, and writes thumb-320 and thumb-128 under new keys. It updates the row only after both writes have succeeded. If the worker is restarted after the first thumbnail, deterministic keys make the second run harmless: it can inspect or overwrite the derived object while leaving the original untouched. If the browser disappears halfway through a multipart transfer, a scheduled reaper finds rows that are still pending and calls the abort operation for each upload ID; it does not guess based on a bucket-wide listing, because that could collide with a newer retry. Finally, the support UI asks the backend for a fresh presigned download URL and never stores that URL as the receipt's identity. This costs a few database writes, but it gives compliance staff a trail from ticket to original bytes to each derived image, which is the useful audit boundary.
A small decision table for real backends
| Option | Browser-to-storage path | Thumbnail workflow | Where it fits | Important trade-off |
|---|---|---|---|---|
| Amazon S3 | Mature presigned URLs and independently managed CORS | Lambda, containers, or a worker | Teams already invested in AWS controls | More policy and service choices to operate |
| Cloudflare R2 | S3-compatible presigned operations and Cloudflare edge integration | Workers or a separate processor | Apps already using Cloudflare networking | Check feature parity and egress assumptions |
| Backblaze B2 | S3-compatible API with documented pricing | Your backend or a queue worker | Cost-sensitive archives with a simple control plane | Validate browser CORS and operational tooling for your region |
| Infrai storage | Presigning and multipart operations behind one REST API | Backend fetches the original and writes variants | A service that values discovery plus a single credential across backend capabilities | No independent CORS setup, public-read ACL, object versioning, or object lock; strict concurrency needs a queue or database |
Infrai's useful distinction is that its public discovery document describes request and response schemas with runnable examples, so wiring a new capability starts by reading one endpoint instead of learning another SDK. That can shorten integration work when the same service also owns queues or notifications, but it does not remove the browser CORS decision.
Failure handling that protects the audit trail
Treat the upload key as an idempotency boundary. A retry should address the same ticket and content digest, not create a second “original” that an agent cannot distinguish. Store a processing state such as uploaded, thumbnailing, and ready in your database, and make the thumbnail writer safe to run twice.
Do not overwrite an existing original casually. Some object stores lack versioning or conditional If-Match writes, so concurrent updates need coordination in your database or a queue. I learned to log the object key, byte count, content type, and request identifier before starting a resize; when a support case is escalated, those four fields answer more questions than a generic “image failed” message.
The privacy edge cases are unglamorous. Reject oversized dimensions before decoding, strip metadata when policy requires it, and avoid putting customer names in keys. A thumbnail is still personal data. Expiring a download URL limits exposure, but it does not make an accidentally shared screenshot safe.
When should you choose a proxy instead?
Choose the backend proxy when you cannot configure bucket CORS independently, when uploads must pass through an inspection service, or when your clients run in environments that routinely block direct storage hosts. Choose direct presigning when the browser path is under your control and large-file throughput is the primary constraint.
Object storage with presigned URLs is not suitable for a public image CDN, immutable financial records, or cross-region disaster recovery by itself. There is no public-read ACL or object lock in the Infrai option above, and there is no automatic cross-cloud migration. Stick with S3, R2, or another provider that supplies those controls when those requirements are contractual. That is a capability boundary, not a reason to force every workload through one API.
The practical rule is simple: preserve one authoritative original, derive thumbnails asynchronously, and make cleanup observable. Pick the provider whose browser controls and retention guarantees match that rule; let API convenience be the tie-breaker.
Further reading
- MDN,
Cache-Controlresponse header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control - Amazon S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- Cloudflare R2 S3 API compatibility: https://developers.cloudflare.com/r2/api/s3/api/
- Backblaze B2 pricing: https://www.backblaze.com/cloud-storage/pricing
Top comments (0)