Short answer: use a presigned single request for small training artifacts, and use presigned multipart when the object size or expected upload time makes restarting the whole object an SLO risk. Presigned POST is a useful policy-shaped single request, but it does not create a smaller retry boundary.
That rule is for a B2B SaaS product where browsers send training artifacts directly to S3-compatible object storage. The application should authorize the upload and retain its state; the browser should carry the bytes. The important choice is the size of the failure domain, not which verb looks nicest in a code sample.
Should browser uploads use presigned POST, PUT, or multipart for large files?
Begin with the object lifecycle. The application authenticates the user, selects an object key, and issues short-lived permission. The browser then uploads directly to storage. A single PUT is easy to observe and easy to explain: one request, one progress stream, one final result. A presigned POST fits a form and policy workflow, but it still has one whole-object transfer boundary.
Multipart changes that boundary. The client splits the object, records the result for each part, and completes the object only after the required part results are present. If a connection disappears after several successful parts, the client can retry the missing part instead of replaying the entire training artifact. That is the useful property.
The trade is operational state. Multipart needs an upload identifier, part numbers, returned part results, a completion step, and an abort path. It also creates more requests and more signing work. A low part size improves retry granularity but raises request volume; a high part size reduces orchestration while making each retry more expensive. I've seen teams pick a fixed threshold from a blog post, then discover that their mobile users have a very different completion-time distribution.
Capacity planning should use the object-size distribution, upload duration, concurrency, retry count, browser memory, and target completion SLO. I don't know a universal byte threshold that survives those variables. Measure the p95 and p99 path you actually operate, then choose the threshold where whole-object retries threaten the SLO.
The failure exercise that exposes the real design
The useful rehearsal is bounded: upload a large artifact, interrupt the connection after several parts have succeeded, reconnect, and restart the browser before completion. This is a test of state ownership, not a benchmark. The invariant is simple: acknowledged parts are not uploaded again without a reason, an incomplete object is never announced as complete, and an abandoned upload has an explicit cleanup decision. I want the exercise to include the awkward transitions that a happy-path test skips — the tab closes, the authorization expires, the completion call is repeated, or the browser reports success before the application has persisted its own record. Those transitions are where an apparently successful upload turns into an object the user cannot find, a duplicate, or a cleanup obligation with no owner.
Pull the network.
On recovery, the client should compare its durable part records with the source file and continue from missing work. The completion request belongs after that comparison. If the operation cannot finish, the client or control plane should abort the multipart upload; otherwise, unfinished fragments become an accounting and capacity problem that the happy-path dashboard will not show.
Measure first.
The application boundary can stay provider-neutral. This Go example makes the decision and records the retry scope; the provider-specific signing and completion calls remain behind an adapter whose contract can be tested independently.
package main import ( "fmt" "os" "strconv" ) type UploadPlan struct { Mode string RetryScope string AbortOnFailure bool } func planUpload(sizeBytes, thresholdBytes int64, unstableNetwork bool) UploadPlan { if sizeBytes >= thresholdBytes || unstableNetwork { return UploadPlan{ Mode: "presigned-multipart", RetryScope: "missing-part", AbortOnFailure: true, } } return UploadPlan{ Mode: "presigned-put", RetryScope: "whole-object", AbortOnFailure: false, } } func requiredInt64(name string) int64 { value, err := strconv.ParseInt(os.Getenv(name), 10, 64) if err != nil || value <= 0 { panic(name + " must be a positive integer") } return value } func main() { plan := planUpload( requiredInt64("FILE_BYTES"), requiredInt64("MULTIPART_THRESHOLD_BYTES"), os.Getenv("UNSTABLE_NETWORK") == "true", ) fmt.Printf("mode=%s retry_scope=%s abort_on_failure=%t\n", plan.Mode, plan.RetryScope, plan.AbortOnFailure) } The adapter should make completion idempotent at the application boundary and should distinguish authorization retries from data retries. A throttled authorization request needs bounded exponential backoff and respect for Retry-After; a failed part needs a part-level retry policy; a canceled operation needs cleanup. Those are different clocks and should not share one generic retry loop.
Buy versus build is an on-call decision
The storage API is only one part of the ownership decision. I put signing, CORS policy, multipart state, cleanup, provider credentials, observability, and incident response in the same review because a small upload demo hides most of that work.
| Approach | Useful when | What the team owns | Main limitation |
|---|---|---|---|
| Native storage integration | The company has standardized on one storage estate | Provider SDK behavior, credentials, CORS, signing, and lifecycle policy | Moving providers becomes an application project |
| Self-hosted signing service | Policy control and deployment control matter more than staff time | Availability, key rotation, API compatibility, and the complete upload state machine | The maintenance and on-call load does not disappear |
| Generic HTTP control plane | Several storage backends must share one application contract | The adapter contract, upload records, cleanup policy, and browser origin configuration | Backend-specific controls still need an explicit mapping |
There is no universally correct buy decision. A small platform team with one backend may rationally keep a native integration because the extra abstraction has no operational payoff. A team supporting several compatible backends may value one HTTP contract and one credential boundary because every service can use it without installing a language-specific SDK. That benefit is about integration surface and ownership, not a claim that the storage layer has identical semantics everywhere.
Where this recommendation does not fit
Multipart is a poor default for tiny objects, short-lived uploads, or systems that cannot persist upload state. The extra requests and state machine add failure modes without shrinking a meaningful retry domain. Stick with a single presigned request when the whole-object retry is comfortably inside the completion SLO.
This browser-direct pattern is also unsuitable when the product requires public object delivery, public-read ACLs, object versioning, object lock or WORM retention, conditional If-Match writes, automatic cross-region replication, hourly expiration, or server-side metadata search beyond prefix-based listing. Those requirements need a storage integration and policy model that explicitly provides them, or an additional coordinator and delivery layer.
Browser CORS is another launch gate. The bucket's origin policy must permit the browser's actual method and headers; a presigned URL cannot make a forbidden browser origin legal. Test preflight behavior from the deployed origin, with the same headers and credentials mode used by the application. A successful server-side upload test proves very little about this path.
The decision rule is therefore narrow and practical: keep a single request while its restart cost is small, move to multipart when the retry domain threatens the SLO, and refuse the browser-direct design when the required object controls are outside its contract. That leaves a measurable architecture instead of a slogan about large files.

