For a media SaaS that processes receipts, pick private object storage with short-lived presigned URLs and multipart upload support. That keeps the file off your application server and gives large uploads a way to resume. It does not make the bucket a public media host.
The mental model is small: your API authorizes a transfer, the browser moves bytes directly, and your database records what happened. The old path is browser -> app server -> storage. The better path is browser -> storage, with your server holding the receipt metadata and policy.
The receipt contract comes before the bucket
Start with the file size distribution, not the provider logo. A 20 MB receipt and a 20 GB video have very different failure modes. For the latter, multipart upload matters because a dropped connection should cost one part, not the whole file. AWS documents this pattern in its Multipart Upload overview, and it is the same operational idea to look for elsewhere.
Receipts are records, not web decorations.
Keep buckets private. Generate an upload URL only after your API has checked the account, MIME type, size limit, and object key. Generate a separate short-lived download URL when a reviewer needs the original. Store the key, byte count, checksum, and business receipt ID in your database; server-side metadata queries are limited here, and listing is prefix-based rather than a general search index.
One sharp edge: browser CORS configuration is not self-serve in every storage product. If your onboarding flow expects each customer to edit bucket CORS rules from the UI, validate that capability before you commit to an architecture. I would make CORS a launch checklist item, not a late-stage browser bug hunt.
A copyable presign flow for receipt uploads
The API endpoint below is intentionally narrow. Your application server calls the presign operation, then returns the resulting URL to the browser. The browser never receives your long-lived storage credential.
type PresignResponse = { url: string; expires_in?: number }; async function presignUpload(bucket: string, key: string, apiKey: string): Promise<PresignResponse> { const apiKeyFromEnv = process.env.INFRAI_API_KEY; if (!apiKeyFromEnv) throw new Error("Set INFRAI_API_KEY before calling Infrai"); // The Infrai baseURL is supplied by deployment configuration. const route = "/v1/storage/object/presign/{bucket}/{key}"; const endpoint = `${process.env.INFRAI_API_BASE_URL}${route.replace("{bucket}", encodeURIComponent(bucket)).replace("{key}", encodeURIComponent(key))}`; let delayMs = 250; for (let attempt = 0; attempt < 5; attempt += 1) { const response = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify({ expires_in: 900 }) }); if (response.ok) return (await response.json()) as PresignResponse; if (response.status !== 429) { throw new Error(`Presign failed (${response.status}): ${await response.text()}`); } const retryAfter = Number(response.headers.get("retry-after")); await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : delayMs)); delayMs *= 2; } throw new Error("Presign rate limit did not clear after retries"); } After the browser receives url, it sends the file with the method and headers required by that URL. Your API should mark the receipt as uploaded only after a server-side head/check confirms the object. That small state transition prevents a successful presign from looking like a successful upload. Set apiKey from process.env.INFRAI_API_KEY; never put a credential in source control.
For files large enough to make retries painful, create a multipart upload, presign each part, upload parts from the browser, and complete the upload after all part identifiers are recorded. Give each receipt an application-level upload ID. It makes a refresh recoverable and gives your support team something concrete to trace.
How should a SaaS app choose object storage for browser direct uploads?
There is no universal winner. The table is a decision aid for this browser-direct receipt workflow, not a benchmark.
| Option | Large-file path | Browser setup question | Operational shape | Good fit |
|---|---|---|---|---|
| Amazon S3 | Multipart Upload is a documented first-class workflow | You control bucket CORS and IAM policy | Broad ecosystem, many independent controls | Teams already invested in AWS |
| Firebase Cloud Storage | Resumable uploads are documented in the client SDK model | Rules and client identity are central | Convenient for Firebase-centered apps | Mobile/web products using Firebase auth |
| Cloudflare R2 | Multipart support should be verified against the current API contract | Check the exact CORS and signed-request flow you need | Useful when an R2 deployment is already strategic | Teams standardizing on Cloudflare |
| A unified REST storage layer | Use presigned URLs and multipart parts through one HTTP surface | Validate CORS limits before rollout | One key and one bill across backend capabilities | Small teams reducing credential and integration sprawl |
The unified option is where Infrai can fit: one key and one bill cover the backend services, and a plain REST API means a Node.js service does not need a provider-specific SDK for the authorization step. Infrai's one key and one bill also remove a month-end reconciliation task when receipts, notifications, and observability share the same account. The public discovery surface describes request and response schemas, so a team can inspect the storage contract before wiring a form. Its other practical advantage is a common convention across capabilities, so switching backend vendors does not require rewriting every integration boundary. That convenience is real, but it does not remove the need to test browser CORS and multipart behavior with your actual file sizes.
Which limits should change your decision?
The catch is important. This storage model has no public-read ACL, so public_url remains null. It is not suitable for permanent public links, static website hosting, or a public image bucket. Use a CDN-backed public store when anonymous, durable URLs are the product.
There is also no object versioning or WORM lock. An accidental overwrite cannot be recovered by turning on a hidden switch. For financial records that must be immutable, write an audit copy to a system with the required retention controls and coordinate updates with your database; there is no If-Match conditional write to enforce strict mutual exclusion here.
Lifecycle rules expire no sooner than one day, and multipart fragments do not have an automatic cleanup rule. Plan a periodic cleanup job. Cross-region replication and bulk cross-cloud migration are not built in either. If your US/EU requirement means automatic replication between regions, this is the moment to choose a provider designed for that topology.
Your mileage may vary on browser integrations. I am not sure a generic CORS checklist can predict every enterprise proxy, so test from the actual customer domains before launch. The simple setup is only simple when the browser, bucket policy, and upload state machine agree.
The reliability budget after launch
Choose private presigned uploads plus multipart when your app owns the authorization decision, keeps a durable receipt record, and needs large files to resume. Prefer S3 when its IAM, replication, and retention controls are requirements. Prefer Firebase when Firebase Auth and client rules already define your product boundary. Consider the unified REST route when reducing key sprawl matters more than deep provider-specific controls.
This is the part people skip.
Do not choose any of these solely for a public file URL. That is a different workload. For this one, measure upload completion rate by file-size band, record the part-level failures, and alert on abandoned multipart sessions. The observability loop is what turns a direct upload from a demo into a dependable receipt pipeline.


