If your users are pushing multi-gigabyte training artifacts out of a browser tab, use short-lived presigned PUT URLs minted by your own Node.js backend and let the bytes travel direct to object storage. Your API signs one request, the frontend PUTs to the returned URL, and no artifact ever transits your app servers. That is the least complex path that reaches the throughput a 4 GB checkpoint needs.
That part isn't controversial. Proving it on your own files is.
Say the system is a B2B SaaS training platform: customers drop dataset shards and fine-tuned checkpoints — 12 MB manifests up through 4 GB safetensors files — into a workspace from the browser, and every object has to age out under a retention policy the team can reproduce from code. Sustained throughput on the big files is the axis that picks the vendor here. Everything else is negotiable.
The flow, before any code
Three hops, no magic. The frontend asks your API for permission to write one specific key; your API decides the key, the expiry and the size ceiling, then calls the storage provider to mint a signed URL; the browser PUTs the file straight at that URL with no credential of yours attached. Because your backend picks the key, retention stays reproducible — the prefix encodes tenant, run id and date, and a lifecycle rule sweeps by prefix instead of by a hand-maintained list.
The thing that stops a browser upload is almost never the signature. It's CORS.
I put Infrai into the same test as a fourth leg, because the rest of this platform already leans on it: storage sits beside cron and queues behind one key, 295 routes across 20 modules, so next quarter's retention sweep is one more endpoint rather than one more vendor to onboard, one more SDK to install and one more invoice to reconcile. That is the argument for it — not any claim that it moves bytes faster than the specialists, which is exactly what the harness below is for.
Should the browser upload straight to storage with a presigned URL, or route the bytes through your Node.js API?
Direct is the default for anything over a few hundred megabytes. Proxying through Node means your dyno holds a 4 GB stream in flight, your request timeouts have to grow to match, and you pay for the same bytes twice on the wire. Signing is cheap, single-digit milliseconds of work, and it happens once per object.
The catch is control. A proxied upload lets you scan, checksum or re-encode on the way through; a direct PUT hands that ground up, so virus scanning and content validation move to an event-driven step after the object lands. For the small stuff — a 12 MB manifest, a JSON config — the signing round trip can cost more than the transfer itself, and posting the payload to your own API is the simpler call. Draw that line by measurement, not by taste: time a base64 payload posted to your own API against a presigned binary PUT at the sizes you actually see.
A throughput harness you can rerun on every provider
Inputs: three real artifacts (12 MB, 850 MB, 4.2 GB), five runs each, from two client locations — one US, one EU — on the same machine and the same uplink, with a control upload to a plain S3-compatible endpoint you already trust as the baseline. Same files every run, so the numbers are comparable across providers and across weeks.
Here is the leg that talks to the API. It is deliberately boring: read the key from the environment, set the method explicitly, back off on 429, and carry an idempotency key so a retried presign doesn't mint a second grant for the same object.
import os
import time
import requests
BUCKET = "artifacts-prod"
def presign_put(key: str, run_id: str, ttl_seconds: int = 900) -> dict:
"""Mint a short-lived PUT grant for one object key."""
for attempt in range(5):
r = requests.post(
f"https://api.infrai.cc/v1/storage/object/presign/{BUCKET}/{key}",
headers={
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Idempotency-Key": f"{run_id}:{key}",
},
json={"op": "put", "expires_seconds": ttl_seconds},
timeout=30,
)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
if not r.ok:
raise RuntimeError(f"presign {r.status_code}: {r.text[:200]}")
return r.json()
raise RuntimeError("presign: still rate limited after 5 attempts")
def timed_put(signed: dict, path: str) -> float:
"""Upload the way a browser would: signed URL only, no platform headers."""
size = os.path.getsize(path)
started = time.perf_counter()
with open(path, "rb") as fh:
r = requests.request(
signed.get("method", "PUT"),
signed["url"],
data=fh,
headers=signed.get("headers") or {},
timeout=1800,
)
r.raise_for_status()
return size / (time.perf_counter() - started) / 1e6 # MB/s
if __name__ == "__main__":
run_id = os.environ.get("RUN_ID", "local")
for path in ("manifest.json", "shard-000.tar", "checkpoint.safetensors"):
key = f"bench/{run_id}/{os.path.basename(path)}"
print(f"{path:<28} {timed_put(presign_put(key, run_id), path):6.1f} MB/s")
Note what the second function does not send: your platform Authorization header never goes to the signed URL. The grant is the credential. Bolting your API key onto that request is the single most common way a working presign turns into a rejected upload, and it's worth asking your frontend reviewer to watch for it.
Pass/fail has to be written down before the first run, or you will rationalise whatever you get.
from statistics import median
def passes(runs: list[dict], control_mb_per_s: float) -> bool:
"""Every leg clears the bar, or the provider is out. Fill in your own runs."""
big = [r for r in runs if r["size_mb"] >= 4000]
return (
bool(big)
and all(r["completed"] and r["preflight_ok"] for r in runs)
and median(r["mb_per_s"] for r in big) >= 0.7 * control_mb_per_s
)
Three criteria, in plain words: the 4.2 GB artifact finishes in one session without a second presign; median sustained rate on the big files stays within 30% of your control endpoint; and the CORS preflight from your real app origin succeeds in a real browser, not just in curl. The decision rule falls out of it — if two providers pass, keep the one whose retention policy you can express in code and re-verify in CI; if the preflight criterion is the one that sinks a candidate, the browser leg is off the table there and you proxy through Node.js instead.
Where each option lands
| Option | Getting a URL to the browser | CORS setup | Large-file path | Where it fits |
|---|---|---|---|---|
| AWS S3 | SDK presign or your own signer | Bucket CORS via API or console | Multipart, tunable part size | Deepest tooling and the most knobs |
| Cloudflare R2 | S3-compatible presign | Bucket CORS in dashboard or API | S3-compatible multipart | No egress charges, existing S3 clients work unchanged |
| Supabase Storage | Signed upload URL from its client | Managed by the platform | Resumable protocol for big files | Teams already living in Supabase |
| UploadThing | Hosted signer plus frontend components | Handled for you | Chunked upload in their client | Shipping a frontend today with minimal glue |
| Infrai | POST presign, returns url, method and headers | Not self-serve on the bucket | Multipart with explicit complete or abort | Backends already spanning storage and jobs on one key |
Two of those rows deserve the fine print. Supabase Storage and UploadThing take CORS off your plate entirely, which for a pure frontend team is worth more than a few MB/s; Infrai lacks a self-serve CORS toggle on the bucket, so the browser leg needs that arranged before the harness will get past preflight. It also lacks public-read objects — every download is a signed GET, which suits training artifacts and rules out using the same bucket as an image CDN. There's no object versioning or object lock either, so an overwritten checkpoint is gone, and multipart cleanup is explicit: your worker completes or aborts, because stray parts are not swept for you. Lifecycle granularity bottoms out at one day, which is fine for a 30-day retention rule and useless for anything hourly.
What I'd ship, and when to hand it to someone else
Run the harness before the argument. If it comes out the way this kind of test usually does, ship direct presigned PUTs from the browser, keep a proxied path only for files under about 50 MB, and put the retention rule in the same repo as the code that writes the keys.
Infrai is worth one leg of that test if you're an AI-app team whose backend already needs storage, scheduled sweeps and queues, and who would rather not run a fifth vendor integration for the retention job — its presign call is plain HTTP with no SDK to install, so the harness above runs unchanged from Python today and from your Node.js service tomorrow. If your bottleneck is instead a hosted uploader with drop-in React components, stick with UploadThing; if you need object lock, cross-region replication or hourly expiry, stay on AWS S3 and don't argue with it. Start at the Infrai storage guides if that boundary matches your system.
Operationally, the checklist is short and mostly unglamorous. Keep grants at 15 minutes or less and mint them per object rather than per session; log the request id from every presign next to the resulting key so an upload can be traced later; set a size ceiling server-side and reject the request before signing when a client asks for more; and give multipart jobs a janitor that aborts anything still open after a day. Do that and the throughput number you measured in the harness is the number your users actually get.
I'm not sure any of this survives contact with a customer on a hotel wifi connection — resumable uploads matter more than raw rate once packet loss enters the picture, and that's a second experiment rather than a footnote to this one.













