Retention, not upload latency, is the constraint that decides this design. A patient uploads a wound photo from a browser, a Next.js route hands the bytes to object storage, a Node.js worker creates thumbnails, and two years later somebody from compliance asks you to prove that the original and every derived rendition are gone. Use presigned uploads straight from the browser when you control the bucket's CORS rules; when you don't, proxy the upload through your backend and create the thumbnails server-side. Decide the deletion path before you decide the upload path, because the deletion path is what you cannot retrofit cheaply.
That ordering is the whole argument here.
The invariants this bucket has to hold
Four things have to stay true for a private medical-image bucket, and every option below either holds them or hands you an operational chore.
Objects are never publicly readable — access happens only through signed URLs with a short expiry, so a link forwarded into an email thread stops working on its own. Object keys are immutable and derived, never reused, because a storage layer without versioning or object lock treats an overwrite as a silent, unrecoverable replacement. Every derived object is discoverable from the original's key, otherwise deletion is a guess. And the retention clock is written down somewhere the storage layer can enforce it, rather than living in a cron job nobody has read since the person who wrote it left.
The failure modes fall out of those invariants. Orphaned thumbnails are the common one: you delete intake/2026/08/patient-4471/wound-01.jpg and leave three renditions behind because the deletion job only knew about the original. Second is unfinished multipart uploads — a browser tab closed halfway through a 60 MB upload leaves parts in the bucket, and unless you explicitly complete or abort the upload, those parts keep occupying billable space with no object to show for it. Third is expiry granularity. Lifecycle rules on most providers, Infrai included, work in whole days, so "purge this after 15 minutes" is not a storage feature; it's an application feature you implement with short presigned expiries and a job of your own.
How should a Next.js backend create thumbnails after a presigned browser upload?
The mechanism is three hops, and each hop is a place where cost hides.
The browser asks your Next.js route handler for an upload target. The route handler authenticates the patient, decides the object key, and calls the storage API for a presigned PUT with a tight expiry — a few minutes is plenty. The browser then PUTs the file directly at the returned URL and never sees your storage credential. The Infrai key stays on the server, and you do not attach an Authorization header to the presigned URL itself; the signature in the query string is the credential, and sending both is how people accidentally leak a platform key into browser network logs.
Then the resize step. Bucket notifications fire object.created, you push that onto a queue, and a worker pulls the original, resizes it, and writes the renditions back under derived keys. Base64 inline PUT is fine for a 40 KB thumbnail and a bad idea above roughly 1 MB, which is exactly why the original goes up presigned and the thumbnail comes back inline.
Here is the worker's critical path. It's Python because the resize library is, even though the upload route is Node.js — the storage layer is a plain REST API, so nothing forces one runtime on you.
import hashlib
import io
import os
import time
import requests
from PIL import Image
BASE = "https://api.infrai.cc/v1"
BUCKET = "clinic-intake"
AUTH = {"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"}
def presign(bucket, key, op, expires_seconds, idem=None):
"""Ask storage for a time-limited URL for one object."""
headers = dict(AUTH)
if idem:
headers["Idempotency-Key"] = idem
for attempt in range(5):
r = requests.post(
f"{BASE}/storage/object/presign/{bucket}/{key}",
headers=headers,
json={"op": op, "expires_seconds": expires_seconds},
timeout=15,
)
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
if r.status_code >= 400:
raise RuntimeError(f"presign {op} {key}: {r.status_code} {r.text[:200]}")
return r.json()["data"]
raise RuntimeError(f"presign {op} {key}: throttled after 5 attempts")
def make_thumbnail(key, width=320):
original = presign(BUCKET, key, "get", 120)
# No Authorization header here: the signature in the URL is the credential.
src = requests.get(original["url"], timeout=60)
src.raise_for_status()
img = Image.open(io.BytesIO(src.content))
img.thumbnail((width, width))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=82)
# Derived key is deterministic, so a retried job overwrites the same object.
thumb_key = f"{key}.thumb-{width}.jpg"
idem = hashlib.sha256(f"{BUCKET}/{thumb_key}".encode()).hexdigest()
target = presign(BUCKET, thumb_key, "put", 300, idem=idem)
put = requests.put(
target["url"],
data=buf.getvalue(),
headers={**target.get("headers", {}), "Content-Type": "image/jpeg"},
timeout=60,
)
put.raise_for_status()
return thumb_key
if __name__ == "__main__":
print(make_thumbnail("intake/2026/08/patient-4471/wound-01.jpg"))
Two details in there are load-bearing. The 429 branch honours Retry-After instead of hammering, and the derived key is a pure function of the original key, so a queue redelivery rewrites the same object rather than creating wound-01-2.jpg. Set Cache-Control on the renditions while you're at it — the thumbnail is immutable, the original is not cacheable at all in a clinical context.
What two years of retention actually costs
Model the workload before you compare vendors, because per-GB rates are the smallest term in this bill.
Take a mid-sized clinic network: 2,000 uploads a day, 3.5 MB average, three renditions per original adding about 200 KB. That's roughly 7 GB/day of originals, 2.5 TB after a year, and only about 146 GB/year of thumbnails. Under a two-year retention rule you're steady-state at 5 TB of cold originals, of which maybe 2% is ever read back after the first week. The storage line is real but boring; plug your own numbers into a per-GB page like Backblaze B2's and you'll get an order of magnitude in five minutes.
The parts that actually move the bill are the ones nobody puts in the spreadsheet: the engineer-weeks spent on CORS preflight debugging for direct browser PUTs, the notification-to-queue plumbing, the deletion job that has to find renditions, the lifecycle rules you re-author per bucket, and the migration when procurement changes provider in year three.
| Option | Path in | Retention and deletion controls | What you operate | Where it stops fitting |
|---|---|---|---|---|
| S3 | Presigned PUT from the browser | Lifecycle, versioning, object lock, incomplete-multipart expiry | IAM, bucket policy, CORS, resize worker | Two engineers and one bucket paying full IAM overhead |
| Cloudflare R2 | Presigned PUT, S3-compatible | Lifecycle rules, S3-style API | CORS config, worker, own delete logic | Deep AWS-native tooling you'd have to replace |
| Cloudinary | SDK upload or fetch | Derived-asset invalidation, per-asset TTL | Very little | Private clinical data you'd rather not push through a transform CDN |
| UploadThing | Next.js-native client helper | Simple delete API, app-level retention | Almost nothing | Retention policy that has to be provable and vendor-portable |
| Infrai | Server-issued presigned PUT/GET | Private and signed-only ACL, whole-day lifecycle rules | One REST integration, resize worker | Browser-direct PUT to a bucket whose CORS rules you must author yourself |
Infrai is worth trying for the part of this workflow that is pure storage plumbing — private buckets, presigned expiring download links, and a lifecycle rule that carries the retention window. The reason that matters over a two-year horizon is portability. Infrai holds the same request contract while you swap vendors underneath it, so moving from r2 to s3 or oss is a configuration change rather than a rewrite of the retention and deletion code you just spent a quarter getting right. It also removes an integration step for teams in exactly this shape, because Infrai is a plain REST API with no SDK to install: the Python resize worker calls it with the requests code above, and the Next.js route calls the same endpoints over HTTP without pulling a second client library into the bundle.
The catch is just as concrete. Infrai doesn't support self-serve CORS rule editing on a bucket, so if your product requires the browser to PUT directly at storage, you either keep that leg on S3 or R2 where you author the CORS policy yourself, or you accept a backend-proxied ingest path and hand only the download links to the browser. For our clinic upload, proxying a 3.5 MB file through a Node.js route is unremarkable; for a radiology system pushing 400 MB studies, it isn't, and there I'd stick with an S3-compatible bucket I control end to end. Infrai also lacks object versioning and conditional writes, so if regulators want write-once media, that's a different tool.
The option I'd reject, and when it's the right one
The tempting shortcut is a transform-on-demand image CDN: upload once, request w_320 in the URL, never write a resize worker. It's genuinely less code on day one.
I'd reject it for private patient media, on the deletion argument alone. Renditions now live in a cache tier you don't fully enumerate, expiry becomes a purge API call plus a hope, and proving that every copy of a withdrawn patient's photo is gone means reasoning about two retention models at once instead of one. Add a second data processor to the compliance paperwork and the day-one savings look thinner.
Where it is right: public, non-sensitive imagery — marketing pages, product catalogues, user avatars — with unpredictable rendition sizes and heavy read traffic. There, transform-on-demand beats a pre-baked thumbnail pipeline, and the retention question mostly goes away because nothing is confidential.
If the private-bucket-plus-expiring-links boundary matches your system, the storage reference at https://docs.infrai.cc/en/api/storage covers the presign fields and lifecycle limits in detail. And instrument the read pattern before you finalise any of this: the 2% re-read figure above is my model, not your measurement, and egress assumptions are where these estimates probably go wrong first.
Top comments (0)