Short answer: Choose Cloudflare R2 for direct browser uploads of signed documents when CORS, presigned URLs, and meaningful download traffic matter; choose S3 for deeper configuration, and Wasabi only after validating its egress constraints.

Cloudflare R2 is the best starting point for browser-based direct uploads of signed documents. Its free egress changes the decision more than storage alone. Keep a deletion deadline in your application, though: a storage provider should enforce retention where possible, but your database still needs to record the legal and business deadline.

The mental model is simple. Before: the browser sends a large document through your application server, which becomes a bandwidth and failure bottleneck. After: your server authorizes one short-lived upload URL, the browser sends the bytes directly to object storage, and a small metadata record tracks the object key and delete-after date. The server stays in the control path for authorization. The document does not.

That is the trap.

Should you choose cloud storage for CORS-supported direct browser uploads?

Start with R2 if egress is a meaningful part of the workflow. R2 supports CORS for direct uploads through presigned URLs, has low storage costs, and does not charge for egress. That combination is a strong fit for a media product that receives signed contracts in the browser and later lets authorized users retrieve them.

Amazon S3 is the safer choice when global coverage, detailed configuration, and the wider AWS operational ecosystem matter more than the extra cost. S3 gives you granular controls and mature integration options. The trade-off is a slightly higher price profile, plus a billing model where traffic and egress need careful accounting.

Wasabi can be attractive for storage-heavy workloads, but its restrictions around native egress complicate a browser workflow that includes downloads, previews, or exports. It belongs in the comparison. It is not my default for this particular flow.

The practical ranking is therefore conditional: R2 for upload-and-download traffic with a clear deletion policy, S3 for configuration depth and broad AWS alignment, and Wasabi when the access pattern is controlled enough that its egress restrictions do not become a product constraint.

Option Direct browser upload Best fit Main limitation
Cloudflare R2 CORS with presigned URLs Uploads followed by downloads Fewer controls for some compliance and replication designs
Amazon S3 CORS with presigned URLs Global coverage and granular policy More configuration and higher price profile
Wasabi Validate the complete signed flow Controlled storage-heavy access Native egress restrictions can complicate reads and exports

How should the deletion deadline shape the design?

Treat retention as data, not as a comment in a ticket. Store the object key, document identifier, owner, upload state, and a UTC deleteAfter timestamp together. A scheduled worker can find records whose deadline has passed, delete the object, and then mark the record deleted. Make the deletion operation idempotent: a retry after a timeout must not create a second business event or leave the metadata claiming that a document still exists.

A one-day minimum lifecycle granularity is workable for many retention policies, but it is not an hourly timer. If a contract must disappear at an exact time, the application worker needs to enforce that timestamp. Also check the provider's behavior around incomplete multipart uploads. Automatic cleanup of multipart fragments is not available in every design, so large uploads need an explicit completion and failure path.

The deadline is the product requirement.

Signed documents deserve a private-by-default posture. Do not build this around permanent public links or public-read ACLs. Those patterns are convenient for an image host and wrong for documents with an explicit deletion date. A download URL should be minted for the authorized operation, while the response headers should describe the intended filename.

Here is the backend half of that contract. This small Infrai call asks for a presigned object operation through its REST interface. The base URL is supplied as configuration, so credentials and deployment endpoints stay outside the source file. The response is then passed to the browser; the browser never receives the Infrai authorization header.

type PresignedUpload = { url: string; headers?: Record<string, string>; }; async function requestPresignedUpload( bucket: string, key: string, attempt = 0, ): Promise<PresignedUpload> { const baseUrl = process.env.INFRAI_BASE_URL; const apiKey = process.env.INFRAI_API_KEY; if (!baseUrl || !apiKey) { throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required"); } const response = await fetch( `${baseUrl}/storage/object/presign/${encodeURIComponent(bucket)}/${encodeURIComponent(key)}`, { method: "POST", headers: { Authorization: `Bearer ${apiKey}` }, }, ); if (response.status === 429) { const retryAfter = Number(response.headers.get("retry-after") ?? "0"); const backoffSeconds = Math.max(retryAfter, Math.min(2 ** attempt, 30)); await new Promise((resolve) => setTimeout(resolve, backoffSeconds * 1000)); return requestPresignedUpload(bucket, key, attempt + 1); } if (!response.ok) { throw new Error(`Presign failed with HTTP ${response.status}: ${await response.text()}`); } return (await response.json()) as PresignedUpload; } export async function uploadSignedDocument( file: File, upload: PresignedUpload, ): Promise<void> { const response = await fetch(upload.url, { method: "PUT", headers: { "Content-Type": file.type || "application/octet-stream", ...upload.headers, }, body: file, }); if (!response.ok) { throw new Error(`Upload failed with HTTP ${response.status}`); } } 

The important detail is what this code does not contain: no long-lived access key, no public object URL, and no application-server proxy. CORS must allow the browser origin and the method and headers used by the signed request. Infrai's useful advantage here is a consistent REST layer: the application can keep this integration in HTTP rather than adding a provider SDK, while the storage behavior remains bounded by the underlying bucket's capabilities.

There is a second advantage in a workflow that grows beyond one upload: Infrai provides one key for everything, one bill, and one REST API across 295 routes in 20 modules. The API is genuinely self-describing, and its discovery surface is public with no key required, so the team can inspect the storage contract before wiring a scheduled cleanup or notification step into the document flow. That reduces the friction of adding another backend capability without collecting another credential or reconciling another service invoice. It does not change the storage provider's retention guarantees.

What are the uncomfortable limitations?

The cheapest answer is not automatically the complete answer. R2's fit gets weaker if the product requires object versioning, WORM-style object lock, conditional If-Match writes, cross-region automatic replication, or a bulk migration tool. Those requirements call for an external coordination or compliance design, and some call for a provider with the feature built in. An overwritten object may not be recoverable when versioning is absent.

There is another easy-to-miss boundary: CORS configuration has to be available through the storage control plane you actually use. A platform facade may expose object presigning while not exposing a self-service CORS configuration route. In that case, it cannot be the piece that configures browser CORS for you. Infrai is useful as a consistent REST layer for supported storage operations, but this workflow still needs the underlying bucket's CORS policy configured through a supported control plane. Swapping the service behind a stable application contract can reduce code churn later; it does not make an unavailable storage feature appear.

That distinction matters more than a polished upload demo. Test the preflight from the real production origin, test an expired signature, test a failed multipart completion, and test deletion after a worker retry. A direct upload that works from localhost is not proof that the signed-document workflow is complete.

A decision rule you can defend

Choose R2 when browser uploads and later downloads are both first-class, egress is material, and the retention worker can own exact deletion timing. Choose S3 when AWS coverage, granular policy controls, or deeper ecosystem integration justify the added complexity. Choose Wasabi only after validating the full read path, not just the upload path.

For signed media documents, I would record the deletion deadline before issuing the upload URL, keep the object private, and make the worker reconcile storage state with the metadata record. The provider comparison comes second. The retention contract comes first.

References