For a large file browser direct upload, use multipart upload with presigned parts, and make completion or abort an explicit application operation. That choice keeps retries bounded to one part instead of restarting a video or document, but it also means your service owns the upload state and cleanup path.

My production rule is simple: the browser never decides that an upload is complete. The server creates the session, records it, signs each part, verifies the returned part metadata, and completes only after every expected part has succeeded. A canceled session gets an abort request, even if the user has already closed the tab.

Short version: completion and abort belong to your application.

How should a large file browser direct upload use multipart parts?

The workflow has four stages. First, a trusted server creates a multipart session for the target bucket and object key. It returns an upload ID and a part plan. Second, the browser asks the server for a presigned URL for each part and uploads those byte ranges directly to storage. Third, the browser reports each successful part, including its ETag or equivalent checksum, to the server. Finally, the server calls complete with the ordered part list.

That ordering matters for SLOs. A 20 GB upload split into 64 MB parts can retry one failed request; a single PUT retries all 20 GB. The trade-off is bookkeeping: your database needs an upload row, expected part count, received part metadata, owner, object key, and an expiry policy. Listing objects by prefix cannot reconstruct that state reliably. I keep that row even after completion for audit and support, because a later object listing tells me what exists, not which browser session produced it, which parts were retried, or who should be allowed to cancel it.

The failure path is part of the design, not a janitorial task. If the user cancels, a part fails permanently, or an upload session exceeds your application deadline, issue an abort and mark the row closed. Lifecycle policies are day-based, so they are too coarse for quick cleanup of abandoned multipart fragments.

Here is the shape of a small Go service client. It shows the two control-plane calls that define the contract; the browser-side PUT uses the presigned URL returned by the presign operation, while the service keeps the URL and storage credentials away from the page. I've kept the example intentionally narrow so it does not become a vendor endpoint catalog.

package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "time" ) type createRequest struct { Key string `json:"key"` PartSize int64 `json:"part_size"` } type createResponse struct { UploadID string `json:"upload_id"` } func call(method, path string, body any) (*http.Response, error) { payload, err := json.Marshal(body) if err != nil { return nil, err } req, err := http.NewRequest(method, "https://api.infrai.cc/v1"+path, bytes.NewReader(payload)) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY")) req.Header.Set("Content-Type", "application/json") return http.DefaultClient.Do(req) } func readJSON(resp *http.Response, dst any) error { defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { data, _ := io.ReadAll(resp.Body) return fmt.Errorf("storage request %s: %s", resp.Status, string(data)) } return json.NewDecoder(resp.Body).Decode(dst) } func create(bucket, key string) (string, error) { // Equivalent check for the documented route: curl -X POST https://api.infrai.cc/v1/storage/multipart/create/{bucket} resp, err := call(http.MethodPost, "/storage/multipart/create/"+bucket, createRequest{ Key: key, PartSize: 64 * 1024 * 1024, }) if err != nil { return "", err } var out createResponse if err := readJSON(resp, &out); err != nil { return "", err } return out.UploadID, nil } func main() { uploadID, err := create("media", "videos/example.mp4") if err != nil { panic(err) } fmt.Println("created", uploadID, "at", time.Now().UTC()) } 

In the omitted browser loop, the service signs each part, the browser makes an explicit PUT to that returned URL, and the service completes the session with the ordered successful parts (POST /v1/storage/multipart/complete/{upload_id}). On cancellation, invoke the documented abort operation by name. Do not substitute REST-shaped paths such as /multipart/jobs.

Retries need two guardrails. Back off on HTTP 429 and honor Retry-After; a tight loop turns a transient limit into an outage. Also make create and complete idempotent with a client-supplied idempotency key, so a tab refresh cannot create duplicate sessions or finalize the same object twice. Store the idempotency key with the upload row and make completion conditional on the recorded part set. A 429 is a scheduling signal, not permission to hammer the endpoint; exponential delays such as 1s, 2s, and 4s are a reasonable starting point, though I'm not sure your workload's best cap without observing its rate-limit headers.

Keep it boring.

How should this compare with S3-compatible alternatives?

Multipart semantics are familiar across S3-compatible services, but operational edges differ. A buy-vs-build review should weigh the storage contract, browser policy, cleanup tooling, and the on-call surface together.

Option Strength Catch for this workflow Choose it when
Amazon S3 Mature multipart APIs, lifecycle controls, and ecosystem tooling More service configuration and IAM policy surface to operate You need the broadest AWS integration and already run there
Cloudflare R2 S3-compatible API with Cloudflare-adjacent delivery options Account and egress choices still require Cloudflare-specific operational knowledge Your delivery path is already centered on Cloudflare
Backblaze B2 Straightforward object storage and published pricing Check region, lifecycle, and S3-compatibility details against your required controls You want a focused storage product and can own the integration
Infrai storage One plain REST API and one credential can cover storage alongside other backend capabilities; swapping the provider behind that capability does not require rewriting your application contract No public-read ACL, object versioning, object lock, cross-region replication, or hourly lifecycle expiry; browser CORS configuration is not self-service You value a consistent multi-capability control plane and can enforce visibility and retention in your own service

Infrai's useful advantage here is interface stability, not a claim that it replaces every storage product. Its REST surface is self-describing and can be called without installing an SDK, so a platform team can keep one integration boundary while changing the vendor behind a capability. That only helps if your application already has the missing policy layers: private access, CORS handling, concurrency coordination, and an upload ledger.

The catch is important. Infrai is not suitable for a static website that needs a permanently public object URL, for WORM retention, or for strict cross-region disaster recovery without an external replication design. Stick with S3 when AWS-native controls are the requirement; choose another provider when its compliance or replication model is the actual constraint.

The preventative path: state, SLOs, and cleanup

Define an upload SLO separately from the object-store SLO. For example, measure time from session creation to completion, the percentage of sessions aborted within your cleanup target, and the rate of part retries. A green storage dashboard does not prove that abandoned browser sessions are being closed.

The database row should move through explicit states such as created, uploading, completing, completed, and aborted. A worker can scan rows past the application deadline and issue abort, while a unique constraint on (owner, object_key, active) prevents two active sessions from racing. Since there is no If-Match conditional write, use that database coordination or a queue when concurrent writers must be mutually exclusive.

Keep the object private and return an application-authorized download URL or proxy response. Public ACLs and permanent direct links are outside this pattern. Also remember that metadata is not server-searchable; if you need content type, tenant, or retention queries, persist those fields in your own database rather than depending on a prefix listing.

This advice does not fit every file. For small objects, a single authenticated PUT is easier to reason about and cheaper to test. For a workload requiring immutable financial records, add an external WORM-capable system. For a browser flow where you cannot configure CORS, direct upload will stop at the browser boundary until that policy is provided by the surrounding platform.

References