Short answer: choose a one-request upload only when replaying the entire object fits the user-facing completion SLO; use a policy-bearing form upload when the browser needs those constraints; and introduce multipart only when partial retry is worth the session state, cleanup, and verification work it creates.
The decision is less about a fashionable file-size threshold than about failure cost. A 20 MB file on a stable internal network and a 20 MB file on a lossy mobile uplink have the same length but very different retry budgets. Start with the workload distribution, the completion objective, and the consequences of asking a user to begin again.
The incident lesson: a successful request is not a completed upload
Consider a bounded production scenario: a browser receives a success response after sending a large object directly to storage, marks the UI complete, and immediately allows the next workflow to run. Later, the workflow cannot find an object with the expected key or expected size. The transport exchange and the product outcome were treated as one state, even though they are separate states.
That distinction changes the design. Authorization, byte transfer, storage verification, and business availability need separate names and separate measurements. The browser can show transfer progress, but the service that owns the workflow should publish availability only after it has checked the object against the upload record. Otherwise a green progress bar becomes a false SLO: it measures the client request, while the user needs a usable object.
Small detail, large consequence.
I would put counters on authorizations issued, objects verified, verification latency, abandoned upload sessions, and retried bytes. The useful ratio is verified objects divided by issued authorizations, sliced by size band and upload mode. An error budget based only on request success can look comfortable while a slow finalization path is quietly consuming the completion budget.
The preventative path can remain deliberately small. Keep the signing implementation behind the storage boundary, persist an expected key and byte count before the browser starts, and make the verification endpoint the only place that changes the business state to ready. The sample is intentionally generic because the boundary, not a particular signing library, is what needs testing.
package upload import ( "context" "encoding/json" "net/http" ) type ObjectStore interface { Size(context.Context, string) (int64, error) } type Uploads interface { Expected(context.Context, string) (key string, size int64, err error) MarkReady(context.Context, string) error } type Handler struct { Store ObjectStore Uploads Uploads } func (h Handler) Verify(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") key, expected, err := h.Uploads.Expected(r.Context(), id) if err != nil { http.Error(w, "upload not found", http.StatusNotFound) return } actual, err := h.Store.Size(r.Context(), key) if err != nil || actual != expected { http.Error(w, "upload not ready", http.StatusConflict) return } if err := h.Uploads.MarkReady(r.Context(), id); err != nil { http.Error(w, "state change rejected", http.StatusConflict) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"state": "ready"}) } This is a control-plane pattern, not a claim that size is sufficient validation. Content integrity, malware handling, retention, and authorization semantics need their own controls. The important invariant is narrower: downstream consumers do not learn that an object is ready from an optimistic browser callback.
How should a browser upload large files when retries are costly?
Treat presigned PUT, presigned POST, and multipart as different failure domains rather than three spellings of the same API. A one-request PUT keeps the state machine compact: authorize an object key, transfer one request, verify the result. Its downside is equally compact and sometimes expensive: a retry can replay the whole object.
A POST-style upload is useful when the authorization needs to carry form-like conditions. It should not be selected because it sounds more protective or because the word "large" appears in a requirement. It still has a whole-object retry cost, so capacity planning asks the same question as PUT: can a user replay this payload inside the completion SLO, and can the ingress path absorb the duplicate bytes during a burst?
Multipart changes the retry unit from the object to a part. That is valuable when near-complete replay would create unacceptable user wait or duplicated ingress, but it also creates an upload session, a part list, bounded client concurrency, explicit completion, and cleanup for sessions that never finish. More state means more ways to leave work behind. Don't hide that cost behind a default threshold.
| Upload shape | Primary benefit | Failure cost | Operational obligation |
|---|---|---|---|
| Single request | Smallest client and control-plane state | Retry may replay every byte | Verify the resulting object |
| Policy-bearing form | Authorization can express form constraints | Retry may replay every byte | Test policy and browser cross-origin behavior |
| Multipart session | Retry can be confined to incomplete parts | Session state can be abandoned | Finalize, observe, and age out sessions |
My decision record starts with p50 and p95 object size, simultaneous upload count, a conservative uplink estimate, retry rate, and the latency budget between authorization and verified availability. From those inputs, calculate a transfer-time envelope and model duplicate bytes under a failure rate you can defend. I'm not sure a static multipart cutoff survives every geography or client population; a mobile-heavy service can reach the replay limit much earlier than an office-only tool.
Capacity planning belongs beside the protocol choice
Retries are capacity. A whole-object retry multiplies ingress and user wait; concurrent multipart parts can multiply both in a different way if the client has no bounds. The review needs an explicit worst-case sequence: assume a cohort of users begins uploads during the same release window, a subset has low available uplink, several transfers retry after substantial progress, and the verification service receives their completion checks in the same interval. Now compare the resulting ingress, open sessions, queued verification work, and time-to-ready against the stated objective. This exercise tends to expose the real bottleneck. It may be the browser's per-file concurrency, a database row held for too long, a verifier that reads metadata serially, or an alert that watches request errors while nobody watches authorization-to-ready latency. Set a per-upload concurrency limit, account for aggregate concurrent uploads, and test the browser's memory behavior with the file sizes the product actually permits. Record the limit next to the rollback condition, because increasing part concurrency is a capacity change even when no storage setting changes. A capacity plan that uses average file size while the SLO is broken by the upper tail is not a capacity plan.
The operational review should also separate managed storage from direct-upload architecture. Buying a storage service can remove media replacement, replication, and some durability work from an on-call rotation. It does not remove responsibility for authorization scope, cross-origin rules, client retry policy, verification, lifecycle policy, workflow state, or the user-visible completion objective. Self-hosting gives a team more control over placement and implementation, while adding upgrades, headroom, durability exercises, and recovery ownership to the roadmap.
| Review area | Managed service question | Self-hosted question |
|---|---|---|
| Capacity | What happens at peak concurrent ingest? | Who adds and rebalances headroom? |
| On-call | Which control-plane failures remain ours? | Who owns recovery outside business hours? |
| Portability | Have we tested behavior, not just API names? | Can we keep the interface stable through upgrades? |
| Cost | Which workload dimension dominates the bill? | Which engineering and hardware costs dominate? |
S3 compatibility is a useful integration target, not proof of behavioral equivalence. Contract-test the exact browser path against every backend you support: signed headers, POST conditions, multipart completion, expiration, metadata visibility, checksums where used, and cross-origin preflight behavior. Run a synthetic transaction that authorizes, transfers, verifies, reads, and removes a harmless test object. It gives the SLO a concrete probe instead of a collection of component dashboards.
What should the verification and security boundary own?
The authorization service should issue the narrowest capability the browser needs: an expected object identity, an expiry that covers a realistic transfer window, and only the methods and headers the client requires. Avoid putting full presigned URLs into logs. An opaque upload identifier is enough to correlate client progress, verification, and eventual cleanup without turning telemetry into a credential archive.
For downloads, keep presentation separate from upload proof. The Content-Disposition response header can communicate inline handling or an attachment filename; browser behavior around that header is documented by MDN. It does not establish that a prior upload was complete, safe, or attached to the correct workflow.
The catch is that direct browser upload is not suitable when every byte must be transformed by the application before storage, inspected synchronously before acceptance, or kept entirely hidden from the client. Route the stream through a controlled service in those cases and budget its bandwidth, queueing, and failure handling explicitly. Likewise, stick with a single request when objects are replaceable, networks are predictable, and the team cannot safely own multipart finalization and session cleanup.
The rollout rule is measured, not universal
Begin with the least stateful upload mode that meets the measured failure budget. Put the mode, size band, authorization limits, verification rule, cleanup owner, cost model, and exit test in the decision record. Then run the synthetic path continuously and review verified-completion latency alongside retried bytes; those two signals expose the trade-off much earlier than a debate over protocol names.
Pricing pages are useful as reminders that storage, requests, retention, and transfer can all affect a workload model, but rates change and should be timestamped in the model rather than baked into the architecture. The protocol has no universal winner. The correct choice is the one whose retry behavior, state ownership, and operating load fit the service objective you are actually willing to support.



