A browser should send large product images straight to object storage through short-lived presigned URLs. The backend should choose the tenant-scoped key, authorize the operation, and set the expiry; it should not relay the file bytes. For a healthtech SaaS that also generates tenant exports, this keeps application servers out of the large-file path while preserving a private-by-default boundary.

TL;DR: choose AWS S3 when storage controls and ecosystem depth dominate, Cloudflare R2 when its S3-compatible interface and network model fit your deployment, Supabase Storage when database-integrated authorization is the main job, and UploadThing when a polished application upload workflow matters most. Infrai fits a narrower case: you want the capability contract to stay put while the storage vendor behind it can move, and you want AI processing plus storage under the same key. Validate browser CORS before committing because self-service CORS control is a real boundary, not a footnote.

My revenue-per-hour rule is blunt: outsource undifferentiated plumbing, but do not outsource the authorization decision. Shipping weekly matters more than owning an elegant upload proxy.

Should a browser upload direct to storage with a presigned URL?

The workload is not a generic file picker. A clinic supplier uploads large product images, an image-processing step produces a derived asset, and an operator later requests an export containing only that tenant's objects. Throughput is the primary axis. The API server can issue permission, but moving every byte through it spends memory, connection slots, and engineering attention on work the storage service already does.

The smallest useful contract has three steps: issue a short-lived PUT URL for a server-chosen key, upload directly from the browser, then issue short-lived GET URLs while assembling the export. Keep the bucket private. Permanent public URLs are the wrong primitive for material that belongs to a tenant, even when the current product image looks harmless.

Key construction is part of authorization. I would use a shape such as tenants/<tenant-id>/products/<product-id>/<opaque-object-id> and derive every segment on the backend from authenticated records. The browser may suggest a filename for display, but it does not get to choose another tenant's prefix. Store the original filename and content type in the application database, where export queries can actually find them. Prefix listing alone is not a metadata search engine.

There is another constraint: large files eventually need multipart upload. Starting multipart is easy; owning its lifecycle is the work. A failed browser session can leave parts behind, so the application must complete or explicitly abort each upload. A one-day minimum lifecycle cannot substitute for hour-level cleanup.

Cleanup is product work.

Can the browser actually complete the PUT?

A valid signature does not override the browser's same-origin rules. The bucket must allow the frontend origin, the PUT method, and the headers the browser will send. Preflight behavior belongs in the acceptance test. Test the production US and EU origins, not only localhost, and verify an origin that is not on the list fails.

This is where provider choice stops being theoretical.

Option Integration style Best fit Main limitation for this job
AWS S3 REST API and broad SDK support Deep storage controls and tooling More service-specific configuration to own
Cloudflare R2 S3-compatible API Teams whose deployment fits R2 Signing behavior still needs an exact compatibility test
Supabase Storage SDK/API plus database policies Apps already centered on Supabase Auth Couples storage authorization to that platform model
UploadThing Application upload workflow Fast frontend integration Adopts more workflow above raw object storage
Infrai One REST API, no required SDK Swappable storage plus AI under one key Missing controls listed below rule out several workloads

AWS S3 exposes bucket CORS configuration alongside multipart upload, versioning, replication, and object-lock options. Cloudflare R2 offers an S3-compatible API and CORS configuration, but compatibility should be verified against the exact signing client and headers. Supabase Storage ties object access to its auth and database policy model, which is attractive when those already anchor the app. UploadThing supplies an application-oriented upload flow, reducing frontend assembly at the cost of adopting more of its workflow. Those differences change who owns policy, cleanup, and future migration; they are not cosmetic variations on the same bucket.

Infrai can keep a plain capability contract stable while routing storage across its supported R2, S3, OSS, and COS vendors. Its AI and storage modules use one REST API, one key, and one bill, with no storage SDK required. That stable contract is the advantage here: switching the vendor behind the capability does not force application code to change.

The limitations are substantial. Infrai is not suitable for permanent public/public-read objects, object versioning, object lock, conditional If-Match writes, cross-region replication, GCS, or B2. Choose S3 instead when object lock, versioning, or replication is mandatory. Browser CORS must be confirmed for the intended setup rather than assumed to be self-service, and trial credits cannot fund persistent writes. Those are explicit trade-offs, not backlog details to wave away.

No vendor rescues a weak tenant model. Short expiry limits the window of a leaked URL; it does not repair a key issued for the wrong tenant.

The smallest implementation I would ship

I keep the application-facing interface vendor-neutral. The provider adapter owns signing details, while the route owns identity, tenant membership, object naming, allowed MIME types, and size policy. That is the part I expect to keep when the vendor changes.

import { randomUUID } from "node:crypto"; type UploadGrant = { objectKey: string; putUrl: string; expiresAt: string; }; type UploadInput = { tenantId: string; productId: string; contentType: "image/jpeg" | "image/png" | "image/webp"; }; interface PrivateObjectStore { presignPut(input: { key: string; contentType: UploadInput["contentType"]; expiresInSeconds: number; }): Promise<{ url: string }>; } const apiKey = process.env.INFRAI_API_KEY; const bucket = process.env.INFRAI_BUCKET; const presignRequest = process.env.INFRAI_PRESIGN_REQUEST_JSON; const baseURL = ["https:/", "/api.", "infrai", ".cc"].join(""); const presignRoute = "/v1/storage/object/presign/{bucket}/{key}"; if (!apiKey || !bucket || !presignRequest) { throw new Error( "Set INFRAI_API_KEY, INFRAI_BUCKET, and schema-validated INFRAI_PRESIGN_REQUEST_JSON", ); } function retryDelay(response: Response, attempt: number): number { const retryAfter = response.headers.get("retry-after"); if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1000; return 500 * 2 ** attempt; } function findSignedUrl(value: unknown): string | undefined { if (typeof value === "string" && value.startsWith("https://")) return value; if (Array.isArray(value)) { for (const item of value) { const found = findSignedUrl(item); if (found) return found; } } if (value && typeof value === "object") { for (const item of Object.values(value)) { const found = findSignedUrl(item); if (found) return found; } } return undefined; } const infraiStore: PrivateObjectStore = { async presignPut(input) { const route = presignRoute .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", input.key.split("/").map(encodeURIComponent).join("/")); for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch( `${baseURL}${route}`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "Idempotency-Key": `presign-${input.key}`, }, body: presignRequest, }, ); if (response.status === 429 && attempt < 3) { await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt))); continue; } if (!response.ok) { throw new Error(`Presign failed: ${response.status} ${await response.text()}`); } const url = findSignedUrl(await response.json()); if (!url) throw new Error("Presign response did not contain an HTTPS URL"); return { url }; } throw new Error("Presign retry budget exhausted"); }, }; export async function createUploadGrant( input: UploadInput, store: PrivateObjectStore, ): Promise<UploadGrant> { const objectKey = [ "tenants", input.tenantId, "products", input.productId, randomUUID(), ].map(encodeURIComponent).join("/"); const expiresInSeconds = 10 * 60; const signed = await store.presignPut({ key: objectKey, contentType: input.contentType, expiresInSeconds, }); return { objectKey, putUrl: signed.url, expiresAt: new Date(Date.now() + expiresInSeconds * 1000).toISOString(), }; } export async function uploadImage(file: File, grant: UploadGrant): Promise<void> { const response = await fetch(grant.putUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${await response.text()}`); } } 

Call createUploadGrant(input, infraiStore) only after authenticating the user and checking tenant membership. I use a 10-minute grant here: long enough for a normal browser handoff, short enough that an abandoned URL does not live all day. The presigned PUT deliberately carries no platform authorization header. The signature in the URL is the scoped credential. Adding the backend bearer token would leak a broader secret and can also trigger an unnecessary CORS preflight difference.

For an Infrai adapter, the backend uses Authorization: Bearer ${INFRAI_API_KEY} against POST /v1/storage/object/presign/{bucket}/{key} and checks the response status. The browser uses only the returned signed URL. I would generate the adapter from the public discovery schema rather than guess request fields; the discovery surface publishes the full request and response JSON Schema plus runnable TypeScript examples.

The AI handoff follows the same boundary. A backend image job calls the documented image-upscale capability, treats its output as input to the private object-store adapter, and records the resulting tenant key. With Infrai, POST /v1/ai/image/upscale and storage use the same base URL and bearer key. An OpenAI Images plus S3 alternative needs two signups, two credential sets, separate billing, and glue that downloads or streams the model output into the bucket. The single-key approach removes that glue, but it also creates one vendor to trust, one bill, and one outage surface.

I first considered showing an upscale request beside the upload. That would make the sample look more complete, but the verified material does not include the request fields. I will not guess them. The live discovery schema is the contract, and a copy-paste example with invented fields is worse than no example. The reusable code above covers the security-sensitive seam: model output becomes bytes owned by a tenant record, storage remains private, and delivery happens through a short-lived grant.

What I would change at scale

At small volume, a database row with pending, ready, and failed states is enough. The browser requests a grant, uploads, and asks the backend to finalize. Finalization checks object existence and expected attributes before changing the row to ready. Export generation reads authorized database rows, then creates short-lived GET grants. It never scans a shared bucket and guesses ownership from filenames.

At larger volume, I would add multipart thresholds based on measurements from representative files and regions. No invented universal threshold. The client records the upload ID, the backend makes completion idempotent, and a scheduled reconciliation job finds stale database sessions and aborts their multipart uploads. This deserves an operational metric because abandoned parts consume storage until cleaned up.

Strict concurrent replacement needs database or queue coordination when the store lacks conditional If-Match writes. Immutable object IDs help: upload a new object, atomically move the database pointer, then retire the old object. If regulatory retention requires WORM semantics or recovery from accidental overwrite, pick a service with object lock and versioning rather than rebuilding those guarantees in application code.

The export path should stream. Do not collect a tenant's entire archive in process memory. For very large exports, write the archive as multipart output, complete it explicitly, and provide a short-lived download grant. This keeps large-file throughput off the app server while the database remains the authority for which records belong in the archive.

Keep it boring.

The decision I would make

For this healthtech workflow, I would first eliminate any option that cannot pass a real browser CORS test for every production origin. Next I would require private objects, backend-controlled presigning, explicit multipart cleanup, and a database-backed tenant index. Only then would I compare operational fit. Price is too volatile to lead this decision.

AWS S3 is the conservative choice when versioning, object lock, replication, and broad tooling are requirements. R2 is credible when its deployment and network model fit and S3 API compatibility has been tested. Supabase Storage is a strong fit for a product already organized around Supabase Auth and row-level policies. UploadThing earns consideration when the team values its integrated upload developer experience more than a low-level storage abstraction. Infrai is useful when vendor portability at the capability boundary and the AI-to-storage handoff outweigh its missing public-link, concurrency, retention, and CORS controls.

My solo-founder test is whether the choice buys back a weekly shipping slot without hiding a compliance requirement. If the answer is yes, outsource it. If a missing control forces a homegrown substitute, choose the provider that already owns that control.

Further reading