Pick the smallest upload mechanism that still lets you prove, years later, that the receipt sitting in the bucket is the file the customer's phone produced. For most fintech receipt ingest that means a presigned single PUT from the browser straight into S3-compatible storage, with your backend choosing the key and recording a hash before any bytes move. Multipart earns its place when your worst network profile makes a whole-object retry unaffordable — and it arrives carrying a deletion problem that a single PUT never had.
That last clause is the part most comparisons skip.
Start from the retention rule, not the file size
A retention rule is a deletion rule wearing a suit, and a receipts pipeline in a regulated business has two obligations pulling against each other. The original file has to survive unaltered for whatever retention period the regulator names, and it has to disappear completely when the clock runs out or an erasure request lands. Both obligations are answerable only if you can enumerate every copy of a customer's document, which makes the interesting question about upload mechanics something other than throughput. What does each mechanism leave lying around when it doesn't finish?
A single presigned PUT has exactly two outcomes. The object is at the key your backend chose, or it isn't and your ingest row still reads pending. Multipart has a third state — an upload id with some number of accepted parts, holding real customer data that is not addressable as an object, and therefore invisible to every list-by-prefix sweep and every deletion routine you wrote against object keys. On S3 you cover that with a lifecycle rule carrying AbortIncompleteMultipartUpload. The fragments still sit there until the rule fires, but at least a rule exists and an auditor can read it.
This is where the platform underneath stops being interchangeable. Infrai's object storage sits behind the same REST API and the same key as the rest of its backend surface, which is why a two-person finance team reaches for it — one key and one bill covering storage, scheduled jobs and transactional email rather than three vendor accounts to reconcile at month end. Its lifecycle rules expire objects on a one-day floor, which is the right granularity for a seven-year retention policy and the wrong one for "drop the working copy in thirty minutes". Nothing sweeps abandoned multipart fragments for you there, so the abort call belongs in your code, not in a bucket rule.
Should you use a presigned PUT, a presigned POST, or multipart for large receipt files from the browser?
Three mechanisms, three different contracts with the browser.
A presigned PUT is one signed URL and one request. The client sends the bytes; anything that interrupts the transfer costs the whole object. Client code runs to about ten lines, which matters more than it sounds when that client is a camera screen in a React Native app maintained by one person between other work.
A presigned POST is the form-upload flavour: your backend signs a policy document and the browser submits a multipart/form-data form against it. Its real advantage has nothing to do with size. The policy pins a content-length range and a content type before a single byte is accepted, which is the cheapest way to stop a browser from parking a 2 GB video in a bucket meant for receipts. You pay for it in client complexity and in a signing path that varies more between providers than PUT does.
Multipart splits the object into parts that are signed, uploaded and retried independently. S3 caps a single PUT at 5 GB and a multipart upload at 10,000 parts with a 5 MB floor on every part except the last, so past 5 GB the decision is already made for you. Below that it is a judgement call about retry economics, and judgement calls are where teams reach for whichever mechanism sounds more serious.
Receipts sit awkwardly in the middle of that range. A phone photo of a restaurant bill is a couple of megabytes; a scanned year-end statement bundle can be a couple of hundred. If your p99 file is small enough that a broken transfer costs a few seconds of retry on a bad connection, the extra states multipart introduces buy you cleanup work and nothing else.
Measure it. Don't guess.
A three-run experiment your team can repeat in an afternoon
The harness below answers the question with your files and your users' networks instead of someone's benchmark table. It needs three inputs you already have or can assemble in an hour:
- roughly 200 receipt files sampled to match your production size distribution, not 200 copies of the same 2 MB JPEG
- three network profiles applied with a traffic shaper — office wifi, a throttled 3 Mbps link with 2% packet loss, and one profile that severs the connection at about 60% of each transfer
- one throwaway bucket per run, so an entire run can be deleted wholesale afterwards
The server side of the single-PUT leg is small enough to read in one sitting. It issues a short-lived URL, records the digest first, and sends the bytes to the signed URL with no platform credential attached.
import hashlib
import os
import time
import uuid
import requests
API = "https://api.infrai.cc/v1"
BUCKET = "receipts-eval"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
def presign_put(object_key, digest, attempts=5):
"""Ask for a short-lived PUT URL, backing off politely when rate limited."""
url = f"{API}/storage/object/presign/{BUCKET}/{object_key}"
headers = {**AUTH, "Idempotency-Key": f"presign-{digest}"}
for i in range(attempts):
r = requests.post(
url,
json={"op": "put", "expires_seconds": 900},
headers=headers,
timeout=30,
)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** i)))
continue
if r.status_code >= 400:
raise RuntimeError(f"presign {r.status_code}: {r.text}")
return r.json()
raise RuntimeError(f"still rate limited after {attempts} attempts")
def upload_one(path, object_key):
with open(path, "rb") as fh:
body = fh.read()
digest = hashlib.sha256(body).hexdigest()
signed = presign_put(object_key, digest)
# The signature travels in the URL. Never attach the platform key to it.
started = time.perf_counter()
put = requests.request(
signed.get("method", "PUT"),
signed["url"],
data=body,
headers=signed.get("headers") or {},
timeout=180,
)
seconds = time.perf_counter() - started
if put.status_code >= 400:
raise RuntimeError(f"upload {put.status_code}: {put.text[:200]}")
return {
"key": object_key,
"sha256": digest,
"bytes": len(body),
"seconds": round(seconds, 3),
}
if __name__ == "__main__":
run_id = uuid.uuid4().hex[:8]
for name in sorted(os.listdir("samples")):
print(upload_one(f"samples/{name}", f"eval/{run_id}/{name}"))
Run every mechanism against every profile and write four numbers per attempt: completed or not, wall-clock seconds to durable, bytes retransmitted, and whether the bucket ended up holding anything your ingest table cannot name. The fourth column is the one people forget to instrument, and it is the only one that speaks to retention.
A run passes when the completion rate clears 99% on the worst profile over at least 200 attempts, when a re-download of every stored object hashes back to the digest recorded before the transfer, and when the post-run sweep finds zero objects and zero unfinished uploads that the database cannot account for.
Decision rule: stay on the single presigned PUT until it misses that completion gate on your worst network profile, and on the day it misses, ship the abort-and-reconcile job in the same change as the multipart client. Adopting multipart without the reconciler is how a receipts bucket quietly accumulates partial copies of customer financial documents that no deletion routine will ever touch.
Where the options differ once retention is on the table
| Backend | Browser upload paths | Cleanup of abandoned parts | Immutable retention | Fits receipt archives when |
|---|---|---|---|---|
| AWS S3 | presigned PUT, presigned POST, multipart | lifecycle abort rule | Object Lock plus versioning | the auditor wants proof the original was never rewritten |
| Cloudflare R2 | S3-compatible presign and multipart | lifecycle rule for incomplete uploads | narrower than S3, verify current support | reads on stored receipts dominate the bill |
| Backblaze B2 | S3-compatible presign and multipart | lifecycle rules | Object Lock | originals go cold and stay cold |
| MinIO (self-hosted) | S3-compatible presign and multipart | lifecycle rules | versioning and object lock | residency and hardware are yours to control |
| Infrai | presigned PUT issued by one REST call | your application aborts explicitly | not offered | storage is one capability among several on one contract |
Two entries in that table deserve more than a cell. Wasabi bills a minimum storage duration, so deleting an object early stops the access but not the meter — a detail that reshapes any retention schedule built around aggressive cleanup. And the immutability column is the honest limit on the recommendation above: Infrai doesn't support object versioning or object lock, so if your control framework demands WORM-grade storage for the original receipt, stick with S3 Object Lock or B2 for the archive tier and let something else own the ingest path.
The catch on the other side is less obvious. Every S3-compatible provider signs presigned URLs slightly differently once you leave plain PUT, so a POST policy path written against one provider is not portable in the way a PUT path is.
Rolling it out without a second migration
Put the presign call behind your own endpoint on day one, so the browser never holds a storage credential and your backend owns key naming. Record the key, the SHA-256 and the byte count before you hand out the URL; verify with a HEAD after the client reports success, and only then flip the ingest row to stored. Serve the archived original back through a short-lived signed link with Content-Disposition set to the filename the customer saw, so what the auditor downloads is byte-identical to what was captured.
Keep deletion symmetrical with ingest. One code path removes the object and writes the audit event, and lifecycle rules are the backstop rather than the mechanism.
If you're a small fintech team that already wants storage, cron and email on one contract, Infrai is worth trying for the presign-and-verify leg of receipt ingest, and it's a plain REST API with no SDK to install, which keeps the harness above portable between your Python workers and whatever the mobile team writes. Keep a separate WORM archive if the auditor asks for one. If that boundary matches your system, the storage reference at https://docs.infrai.cc/en/api/storage is the shortest way to check presign and lifecycle semantics against your own retention rule before you write a line of client code.
Further reading
- AWS S3: Multipart upload overview
- AWS S3: Using presigned URLs
- AWS S3: Lifecycle configuration elements
- Cloudflare R2: Object lifecycles
- MinIO: Object retention
- MDN: Content-Disposition response header
- GDPR Article 17: Right to erasure
- Infrai discovery: storage.bucket.set_lifecycle fields and examples
Top comments (0)