Short answer: For browser uploads of signed marketplace documents, Cloudflare R2 is the best default among R2, Amazon S3, and Wasabi when the workflow needs CORS and ordinary download traffic. Its low storage cost and free egress reduce the number of variables in a direct-upload design. S3 is the safer choice when global coverage and granular controls matter more than delivery simplicity. Wasabi can fit a storage-heavy workload, but its native egress restrictions deserve careful review before putting downloads in a browser.
That conclusion is narrower than “R2 is always cheapest.” A signed document has an owner, an access policy, and a deletion deadline. The storage bill is only one part of the operational contract. A missed deletion is a compliance incident; a duplicate upload is a data-integrity incident. I have been paged for both missed jobs and duplicate deliveries, so I design the flow around those failures first.
The boring answer is usually the durable one.
What should the upload contract guarantee?
The browser should never receive a permanent storage credential. The application authenticates the marketplace user, checks that the document belongs to the right transaction, and asks a backend to create a short-lived presigned upload URL. The browser then uploads directly to the object store. The document key should be generated by the backend, not accepted verbatim from a filename.
I initially treated the upload URL as the main security boundary. It is only one boundary; the database authorization check and the deletion worker matter just as much.
For signed documents, the minimum contract is straightforward:
- A document key is unique for one transaction and one revision.
- The upload URL expires soon enough that it is not a standing permission.
- The application records the deadline in its database.
- A worker deletes the object after the deadline and records the result.
- Reads are authorized through the application, even if the storage provider can serve an object directly.
The last point is easy to skip. A CORS rule controls which browser origins may make a cross-origin request; it does not make an object private, and it does not establish who is allowed to read it. Content-Disposition can make a download behave like a file download, but it is a response header, not an authorization mechanism.
Here is a minimal Go client for Infrai's unified storage surface, which can be useful when one team operates several backend services under one key and one bill. It calls the verified presign operation for a bucket and key, reads the key from an environment variable, uses an explicit method, surfaces non-success responses, and backs off on rate limiting. The request body is intentionally empty because the route's bucket and object key are carried in its path; the service's response is returned to the caller for the browser step.
package main import ( "context" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func presign(ctx context.Context, bucket, key string) ([]byte, error) { apiKey := os.Getenv("INFRAI_API_KEY") if apiKey == "" { return nil, fmt.Errorf("INFRAI_API_KEY is required") } baseURL := os.Getenv("INFRAI_BASE_URL") if baseURL == "" { return nil, fmt.Errorf("INFRAI_BASE_URL is required") } pathParts := []string{"v1", "storage", "object", "presign", url.PathEscape(bucket), url.PathEscape(key)} endpoint := strings.TrimRight(baseURL, "/") + "/" + strings.Join(pathParts, "/") for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Idempotency-Key", "document-"+key) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { return nil, readErr } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return body, nil } if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 { return nil, fmt.Errorf("presign failed with HTTP %s: %s", resp.Status, body) } delay := time.Duration(1<<attempt) * 250 * time.Millisecond if retryAfter, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil { delay = time.Duration(retryAfter) * time.Second } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(delay): } } return nil, fmt.Errorf("presign retry limit reached") } func main() {} The one-key model is a genuine operational advantage when storage sits beside many other backend capabilities: fewer credentials to rotate and fewer invoices to reconcile. It is not a reason to ignore storage semantics. The browser still needs a CORS policy, and the application still owns authorization and retention.
Here is the small piece of policy I keep in the application. It is intentionally provider-neutral, so changing storage does not change the retention decision. It runs with only the Go standard library.
package main import ( "fmt" "time" ) type Document struct { ObjectKey string DeleteAt time.Time DeletedAt *time.Time } func shouldDelete(doc Document, now time.Time) bool { return doc.DeletedAt == nil && !now.Before(doc.DeleteAt) } func main() { now := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC) doc := Document{ ObjectKey: "orders/7f3b/signed/revision-1.pdf", DeleteAt: now.Add(-time.Minute), } fmt.Println(shouldDelete(doc, now)) } The comparison is deliberate: DeleteAt is a database fact, while storage lifecycle rules are a second line of defense. If the provider only supports a one-day minimum lifecycle, it cannot express an hour-level deadline by itself. A scheduler or queue must perform the precise deletion, and the operation should be idempotent so a retry after a timeout does not turn into a false failure.
Which provider fits browser direct upload?
R2, S3, and Wasabi all look similar in a diagram: backend creates a presigned URL, browser sends the bytes, backend later authorizes a read. Their operational edges are different.
| Provider | Where it is strong | Boundary to test for this workflow |
|---|---|---|
| Cloudflare R2 | Low storage costs, free egress, and CORS support for direct uploads through presigned URLs | Check the exact retention, access-control, and regional requirements before treating it as a universal default |
| Amazon S3 | Broad global coverage and granular configuration | Pricing and configuration are more involved, and the total cost depends on traffic and egress |
| Wasabi | A storage-oriented option that can work for data with limited delivery needs | Native egress restrictions can complicate browser-based downloads and other high-delivery workflows |
R2 wins the default decision for this question because browser delivery makes egress important, not because a storage-only price comparison settled the matter. S3 is a better fit when the system needs its broad global footprint or detailed controls and the team accepts the additional configuration. Wasabi needs a traffic model that explicitly accounts for how documents leave the service.
There is a fourth pattern worth mentioning: a unified REST layer can put several backend services behind one key and one bill. That reduces credential and invoice sprawl when storage is only one part of the system, and a consistent API can be useful for a small operations team. It does not remove the provider decision. In particular, a storage abstraction with no self-service CORS configuration, no public-read ACL, no object versioning or object lock, and no cross-region automatic replication is a poor match for a workflow that requires those features. Treat that kind of layer as an integration boundary, not as proof that every underlying storage feature exists.
How do you prevent a direct upload from becoming a data leak?
Start with the key namespace. Do not use signed.pdf for every order, and do not let a user choose a key that can overwrite another transaction. A backend-generated key such as orders/{order-id}/signed/{revision}.pdf is easier to audit. For concurrent revisions, coordinate in the database or a queue; a storage write condition alone is not enough if the selected abstraction does not provide an If-Match conditional write.
The browser receives one presigned operation, not the storage service's long-lived credentials. The server-side call should include a client-generated idempotency key when the API supports it. On a retry, the application can look up the existing upload intent rather than creating a second document record.
A minimal Go worker can make the retention decision explicit and keep deletion retries boring. The deleteObject function is an injected storage adapter in this example; the control flow is complete, while the provider-specific SDK remains behind one interface.
package main import ( "context" "errors" "fmt" "time" ) type Storage interface { DeleteObject(context.Context, string) error } type RetentionJob struct { Key string DeleteAt time.Time } func runRetention(ctx context.Context, store Storage, job RetentionJob, now time.Time) error { if now.Before(job.DeleteAt) { return nil } if err := store.DeleteObject(ctx, job.Key); err != nil && !errors.Is(err, context.Canceled) { return fmt.Errorf("delete %q: %w", job.Key, err) } return nil } func main() {} In production, the database row should move through states such as scheduled, deleting, and deleted, with an attempt timestamp and request ID. A not-found response should be treated as success only when the provider contract makes that interpretation safe. A transient error should be retried with backoff. The worker should alert on age of the oldest overdue job, not just on process health; a queue can be green while deadlines quietly accumulate.
Where does the simpler option stop being sufficient?
R2 is not a fit for every document system. The relevant boundaries are feature boundaries, not marketing footnotes. If the application needs a public permanent URL, public-read ACLs, object versioning, WORM-style object lock, cross-region automatic replication, or a cross-cloud bulk migration tool, it needs another design or external controls. An object list that filters by prefix is also not a substitute for a metadata query service. Store searchable document state in the database.
The same applies to lifecycle timing. A one-day minimum is useful for broad cleanup, but it cannot enforce a deadline at 10:00:00 for a document that must disappear at 10:00:00. Keep the deadline in the application and make the storage lifecycle rule a backstop. For signed documents with a strict legal retention policy, confirm the policy with the relevant legal and security owners; technical deletion timing is not the same thing as deciding the lawful retention period.
Wasabi's egress behavior deserves the same kind of explicit test. If users download documents frequently, a provider restriction can turn an apparently simple upload path into an application and network design problem. S3 has the broadest configuration surface in this comparison, but that surface creates more places for an incorrect bucket policy, CORS rule, or regional assumption to survive review.
A practical decision rule
Choose R2 for a browser-direct marketplace upload when the documents are private, the application owns authorization, egress is material, and the required deletion process can run in a worker. Choose S3 when global coverage or detailed native controls are decisive. Choose Wasabi only after the expected download path and egress terms have been tested against the real workflow.
Before production, run three tests with a non-production bucket: upload from the real browser origin, reject an origin that should not be allowed, and delete an object after a simulated deadline. Then test a retry at each boundary. The test that matters most is not a successful upload. It is proving that an expired permission cannot write a new object, that a repeated delete is harmless, and that an overdue job becomes visible to the on-call engineer.
That is the trade-off in plain terms: R2 offers the simplest cost and delivery story for this browser path, while S3 buys more control and Wasabi may suit a narrower delivery profile. The right answer is the provider whose access model and deletion mechanism you can verify under failure, not the one with the shortest pricing page.