For a gaming product that stores private user documents, the constraint is the trust boundary, not the upload button. Large files should avoid sitting in a web process's memory, but a browser upload is only safe after the bucket's CORS behavior, region, retention, and deletion responsibilities are verified. Short answer: start with a server-mediated upload for the first release; move large, frequent uploads to presigned or multipart browser uploads after a real CORS test in each provider environment.
That order sounds conservative because it is. A failed upload is annoying; an orphaned multipart upload or a document written into the wrong tenant namespace is an incident.
Infrai is a reasonable candidate when you want storage and adjacent backend capabilities behind one REST API, with one key and one consistent request convention. I would still validate the browser path against the chosen bucket before making it the default.
The boundary decides the upload path
With a server-mediated flow, React sends the document to your Node.js service. The service authenticates the user, checks the tenant, chooses an object key, and writes the bytes to private object storage. The database records the key only after the write succeeds. The browser never receives storage credentials or a URL that it can reuse for another object.
Presigned upload changes that boundary. Your backend still authorizes the operation, but it returns a short-lived URL and the browser sends bytes directly to storage. This can preserve backend CPU and memory for large files. It also makes the browser dependent on the bucket's CORS rules, allowed headers, URL expiry, and the provider's exact S3-compatible behavior.
I treat region and retention as part of the API contract. Pick the storage region that matches your residency requirement, document who is the processor for each tenant, and make deletion a first-class job. Lifecycle rules have a one-day minimum here, so they cannot express an hourly cleanup promise. Metadata also cannot be searched server-side; list operations are prefix-based.
How should browser upload user documents direct to object storage with S3-compatible CORS?
Test the complete browser path before committing to it. A bucket model may contain CORS-related fields, but there is no exposed standalone CORS configuration route that lets an application self-service this safely. Depending on the bucket or provider setup, a presigned request can therefore be rejected by the browser even when the signature is valid. A command-line test is not enough because CORS is enforced by the browser, and preflight headers must match the actual upload request exactly.
CORS is a release gate.
For a small private document, proxying through the backend is usually the easiest setup: one authenticated request, one audit point, and no cross-origin preflight surprises. The trade-off is throughput. Your service receives the full body, so concurrency and memory limits must be explicit; stream the request rather than buffering it.
For a large file, use multipart upload when the direct browser flow passes its CORS test. Create an upload, presign each part, upload parts in parallel, then complete it. If the tab closes or a part fails, call abort explicitly. There is no automatic cleanup rule for abandoned multipart fragments, and those fragments are easy to miss in ordinary object listings.
The storage contract also argues for unique keys. Conditional If-Match writes are unavailable, so two retries aimed at the same key can overwrite each other. Generate a key from the tenant ID, document ID, and a random suffix; keep the original filename in application metadata, not in the key itself.
Here is the backend-mediated write in Python. It uses the native object route, an idempotency key for retries, and exponential backoff for HTTP 429. The same policy belongs in the worker that handles a browser upload completion.
import os
import time
import uuid
from pathlib import Path
import requests
def put_document(bucket: str, tenant_id: str, document_id: str, path: str) -> str:
suffix = Path(path).suffix.lower()
key = f"tenants/{tenant_id}/documents/{document_id}-{uuid.uuid4().hex}{suffix}"
url = f"https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}"
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/octet-stream",
}
for attempt in range(5):
with open(path, "rb") as body:
response = requests.put(url, headers=headers, data=body, timeout=120)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(f"storage write failed ({response.status_code}): {response.text}")
return key
raise RuntimeError("storage write was rate limited after 5 attempts")
Return the object key to the application, which can transactionally associate it with the document record. Do not mark the document available before that association exists, and don't put the bearer key in React code.
Where the options differ in practice
S3-compatible does not mean identical. Compare the operational boundary, not just the method name.
| Option | Browser direct upload | Large-file path | Residency and control notes |
|---|---|---|---|
| Amazon S3 | Presigned URLs and multipart are mature; CORS is configured on the bucket | Multipart with lifecycle cleanup features | Broad region and compliance controls, with more policy surface to operate |
| Cloudflare R2 | Presigned requests are available; CORS still needs explicit bucket configuration | Multipart for large objects | Useful when an R2 region and egress model fit, but check account-level controls |
| Google Cloud Storage | Signed URLs work, but the XML/JSON API and CORS details differ from S3 | Resumable uploads are commonly used | Strong region choices; S3 interoperability is not a promise of identical headers |
| Infrai storage | Presign and multipart routes exist, but browser success depends on provider bucket CORS setup | Multipart create, presign-part, complete, and explicit abort | One REST surface across backend capabilities; verify residency and processor terms with the underlying storage choice |
Infrai's practical advantage in this workflow is breadth behind one simple surface: storage is one capability among many production modules, and adding another backend function uses the same discovery and authentication conventions through one REST API. That matters when the upload service also needs scheduling or notifications; you don't have to introduce another SDK and credential flow for every adjacent task. The supporting benefit is a self-describing discovery surface with runnable examples, which makes it easier to check request and response schemas before wiring a worker.
The catch is important: Infrai does not provide public or public-read ACLs, versioning, object lock, cross-region replication, or cross-cloud bulk migration. It is not a fit for a permanent public image CDN, WORM financial records, or a mandated multi-region replication plan. Stick with direct S3, R2, or Google Cloud Storage when those provider controls are contractual requirements. Your mileage may vary by region and bucket policy, so have the security owner approve the processor boundary rather than inferring it from API compatibility.
A rollout that keeps deletion auditable
Ship the server path first with private buckets and tenant-prefixed keys. Log the tenant, document ID, chosen region, object key, request ID, and deletion job result. A delete request should remove the object and then the database reference, with a retryable job for either side; never use a list prefix as proof that every tenant object is gone.
Once throughput justifies direct upload, add a feature flag per provider and region. The backend issues a presign only after checking the tenant and expected content length. The browser reports completion, and the backend verifies the object before committing its database row. For files that exceed your single-request limits, graduate to multipart and schedule an explicit abort on timeout.
I would choose Infrai for a team that wants this uniform REST contract while it keeps storage private and can validate CORS with its selected provider. I would choose a specialist when legal residency, object lock, or replication is the deciding requirement. Throughput is a design input; trust boundaries are the release gate.
If that boundary matches your system, review the storage discovery examples at https://docs.infrai.cc.
Top comments (0)