Short answer: choose object storage for browser direct upload only after a real-browser test proves presigned upload, restrictive CORS, signed download behavior, European data-location requirements, and full-path pricing under your workload; keep authorization and verification in a small application control plane, and keep file bytes off that plane unless policy requires synchronous inspection.
There is no universally best service among Cloudflare R2, AWS S3, Bunny Storage, and Backblaze B2. The useful decision is narrower: which candidate can satisfy the same written contract, remain operable by the team on call, and meet the workload's residency and cost constraints? A cheap-looking capacity line is weak evidence. Request classes, transfer paths, retries, retention, support, and engineering time belong in the model too.
This is a runbook for reaching that decision without turning a product comparison into a logo table.
How should browser direct upload without a proxy server handle CORS and signed download?
Split the system into a control plane and a data plane. The authenticated browser asks the application for an upload grant. The application validates tenant, object size, media type, and naming policy; records an upload intent; and returns a short-lived presigned URL plus the exact headers covered by the signature. The browser sends the bytes to storage. A verifier then checks the stored object before the application marks it ready. Downloads use a separate authorization decision and a separately limited URL.
CORS is browser policy, not write authorization. Its job is to tell the browser which origins, methods, and request headers may cross an origin boundary. A wildcard policy doesn't turn an unsigned upload into a safe one, and a correct signature doesn't help if the browser's preflight rejects a header. Allow the production origin, the upload method actually used, and the smallest header set the client sends. Test from the real origin; a command-line client does not enforce browser CORS.
Keep object keys server-controlled. Accepting a bucket or complete key from the browser creates an authorization surface that is much harder to audit than accepting an intent such as media type and byte count. The control plane should bind the grant to the authenticated tenant, generate an unpredictable operation ID, choose the key prefix, impose an expiry, and log the ID without logging the signed query string.
Signed download is another contract, not the upload contract in reverse. Decide whether the response should display inline or download as an attachment, and test the filename behavior in supported browsers. The Content-Disposition response header describes inline versus attachment presentation and can provide filename parameters; browser details and filename encoding deserve their own test cases, as the MDN reference explains.
The catch is immediate inspection. Direct-to-storage upload is not suitable when every byte must pass synchronous malware scanning, transformation, or a legal policy decision before initial persistence. Put a controlled ingest service in the data path in that case, then capacity-plan its bandwidth, concurrency, backpressure, and failure domain. Don't disguise a mandatory proxy as a background verifier.
Turn the browser path into an SLO
Define a valid upload attempt before choosing a provider. A practical end-to-end service-level indicator is the proportion of valid intents that become verified objects within a stated time window. Measure from intent creation, not from the first storage byte, because users experience signing delays, preflight failures, transfer time, and verification lag as one operation.
Keep a second objective for grant issuance. That separation tells the on-call engineer whether the application control plane or the byte path is consuming the error budget. Split failures into authorization rejection, browser preflight, storage response class, client cancellation, expiry, and post-upload verification. Avoid claiming success merely because a URL was issued.
Retries are capacity, too.
A browser that receives a rate-limit response may retry, but the retry must retain one operation identity, use bounded backoff, and stop with a visible terminal state. Otherwise 17 attempts can look like 17 independent uploads, inflate requests and traffic, and leave operators unable to distinguish one unhappy user action from a demand spike. The precise retry ceiling depends on expiry, object size, and client connectivity; I'm not sure a universal number would be defensible. A load test with the expected object-size distribution and injected rate limiting will resolve it for a particular system.
Capacity planning should include peak intent requests per second, concurrent transfers, p50 and p99 object sizes, multipart part counts where applicable, verification work, retention growth, download traffic, and retry amplification. Include abandoned work. Averages hide the exact large-object concurrency that exhausts browser connections or makes a ten-minute grant unrealistic.
The dashboard needs an upload ID that joins browser telemetry, grant issuance, verification, and the final application state. Record issuance latency, reported preflight outcome, bytes attempted, attempt count, verification delay, and final state. Never record credentials or a complete signed URL. Alert on end-to-end completion and error-budget burn; a storage console alone cannot report a preflight that the browser refused to send.
No magic here.
Compare the operating boundary before comparing price
Use the same proof-of-concept workload for Cloudflare R2, AWS S3, Bunny Storage, and Backblaze B2, but do not infer that similarly shaped object APIs create identical operations. For every candidate, verify the feature rather than relying on the category label: presigning behavior, required signed headers, CORS configuration, upload size and multipart semantics, download response controls, object-location evidence, lifecycle behavior, audit access, identity ownership, and deletion evidence.
“Europe” needs a written meaning. It might mean the primary object location, every replica, encryption-key control, the processing location, the delivery route, or a contractual residency boundary. An edge close to a European user does not by itself prove where the durable object or its replicas reside. The policy owner should state the constraint and the proof they will accept before engineers build the comparison matrix.
Price deserves the same discipline. The AWS S3 pricing page, for example, separates several usage dimensions rather than offering one universal storage number. That is enough reason to reject a GB-month-only comparison. For each candidate, feed current published terms into the same model: stored bytes over time, operation classes, outbound transfer along the real delivery path, replication or copies, retention and early-deletion effects where applicable, support, observability, and the engineering cost of an additional identity and billing boundary. Current calculators and terms are the source of truth because rates and regional conditions can change.
| Decision | Managed object storage | Self-hosted object storage | Controlled ingest proxy |
|---|---|---|---|
| Data-plane ownership | Provider operates the storage fleet | Platform team owns capacity, upgrades, durability design, and recovery | Team owns transfer capacity; storage may still be managed |
| Browser upload path | Direct when signing and CORS pass the contract tests | Direct if the deployed endpoint and policy pass the same tests | Bytes traverse application-controlled compute |
| On-call load | Integration, policy, client behavior, and vendor boundary | Integration plus the complete storage service | Integration plus bandwidth, scaling, and backpressure |
| Best fit | Commodity object retention with an acceptable external boundary | Requirements justify owning the fleet and its failure modes | Inspection or transformation must happen before persistence |
| Main limitation | Account, location, feature, and exit constraints remain | Durability and operational toil move onto the team | Couples application capacity to file traffic |
The buy-versus-build answer is usually an ownership decision disguised as a feature decision. Managed storage removes a large fleet from the pager, but it does not own tenant authorization, safe naming, lifecycle intent, browser UX, or application-level verification. Self-hosting can satisfy a specialized control boundary, yet it is unsuitable when the team cannot staff upgrades, repair, capacity forecasting, and recovery tests. Stick with an existing cloud boundary when adding another provider would create more identity, billing, and on-call work than the measured benefit justifies.
Implement a small signing contract
The application-facing interface should express platform policy while leaving provider signing behind an adapter. This Go example intentionally omits a vendor SDK. It validates policy input, creates a server-owned key, and asks an adapter for the exact upload URL and headers. Production code should inject a cryptographically secure ID generator and derive the tenant from the authenticated principal.
package upload
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
type Intent struct {
TenantID string
Filename string
MediaType string
SizeBytes int64
}
type Grant struct {
UploadID string
Key string
URL *url.URL
Headers map[string]string
Expires time.Time
}
type PutSigner interface {
SignPut(context.Context, string, string, time.Time) (*url.URL, map[string]string, error)
}
type IDGenerator interface {
NewID() (string, error)
}
func IssueGrant(ctx context.Context, signer PutSigner, ids IDGenerator, now time.Time, in Intent) (Grant, error) {
if in.TenantID == "" || in.SizeBytes <= 0 || in.SizeBytes > 500<<20 {
return Grant{}, errors.New("upload policy rejected the request")
}
if in.MediaType != "image/jpeg" && in.MediaType != "application/pdf" {
return Grant{}, errors.New("media type is not allowed")
}
id, err := ids.NewID()
if err != nil {
return Grant{}, fmt.Errorf("create upload ID: %w", err)
}
name := strings.ReplaceAll(in.Filename, "/", "_")
key := fmt.Sprintf("tenants/%s/uploads/%s/%s", in.TenantID, id, name)
expires := now.Add(10 * time.Minute)
signedURL, headers, err := signer.SignPut(ctx, key, in.MediaType, expires)
if err != nil {
return Grant{}, fmt.Errorf("sign upload: %w", err)
}
return Grant{UploadID: id, Key: key, URL: signedURL, Headers: headers, Expires: expires}, nil
}
The 500 MiB and ten-minute limits are example application policy, not provider claims. Replace them with limits justified by the workload and test the expiry against slow clients. Persist the intent before returning the grant, require completion to reference its upload ID, and verify the stored key, expected size, and allowed content before publishing it. Content-Type and a filename are claims from a client, not proof of safe content.
Portability also has a limit. Signatures, multipart workflows, lifecycle controls, and event delivery can differ even when adapters expose the same small interface. Contract-test only the behavior the application uses, keep provider-specific options inside the adapter, and avoid promising a zero-work migration. An abstraction that hides every difference tends to hide the operational decisions too.
Verify rollout and rollback before moving data
Run the acceptance matrix from the actual application origin: allowed upload, forbidden origin, expired grant, altered signed header, unsupported media type, oversized intent, cancellation, bounded retry, verification delay, and signed download with both inline and attachment behavior where required. Test a small object and the maximum policy size. Every result should identify the failed boundary without exposing a signed URL.
Deploy a new adapter to internal traffic, then a limited cohort. Compare completion rate, issuance latency, end-to-end latency, retry amplification, and verification lag with the existing path. Write the rollback threshold before rollout. Rollback should stop issuing new grants through the candidate adapter while allowing already issued grants to finish until expiry; upload intents remain reconcilable because their IDs and target keys were recorded first.
Treat that overlap window as a state-machine exercise — not a DNS trick. At the moment of rollback, some browsers will hold unexpired grants for the candidate, some will be transferring bytes, some objects will be awaiting verification, and new intents will target the previous adapter. The reconciler must therefore look up each intent's recorded adapter and key rather than assume that the currently active adapter owns every pending upload. Let issued grants reach their natural expiry, continue verification against their recorded destination, reject a second completion transition for an intent already marked ready, and expose the count and age of pending intents on the rollout dashboard. Once the last candidate grant has expired and its verification queue is empty, disable the adapter. This sequence avoids abandoning legitimate uploads while preserving a single application-level terminal state, and it gives on-call staff a bounded condition for declaring rollback complete instead of asking them to infer safety from falling traffic.
Don't move existing objects merely to test grant issuance. A reversible control-plane switch is cheaper to reason about, reduces the rollback blast radius, and reveals whether the team's abstraction is real. Move retained data only after the request path, download semantics, lifecycle policy, audit trail, cost model, and recovery procedure have passed review.
The final choice is the candidate that passes this contract with acceptable error-budget risk and operational ownership. If none does, change the architecture or the requirement. A ranking assembled from headline storage prices cannot make that decision.
Top comments (0)