The operational constraint is not how quickly an application can mint a URL. It is whether the signing path, browser policy, object namespace, and cleanup process still behave predictably when uploads surge and clients retry.
Short answer: keep the bucket private, let the application authorize a narrowly scoped upload and return a short-lived presigned PUT URL, send the file from the browser directly to object storage, and issue a separate short-lived signed download URL only after the application has verified the object record.
Treat signing as a control plane with an SLO. Treat file bytes as a data plane that should never cross the Node.js or Express process. This pattern fits a React client and an Express authorization route even though the implementation below uses Go to make the protocol boundaries explicit: the browser receives a URL, a method, required headers, an object identifier, and an expiry. It doesn't receive storage credentials.
What should a React, Node.js, and Express browser upload contract contain?
A direct-to-storage design has four steps. First, React asks the application for upload authorization using the user's normal session. Second, Express applies application policy: identity, object size, media type, quota, and destination prefix. Third, the browser performs the presigned PUT against the returned storage URL. Last, the application records completion only after it has evidence that the expected object exists. A successful authorization response is not proof of a completed upload.
Keep the contract boring. A useful response contains object_key, upload_url, method, required_headers, and expires_at. The method should be fixed to PUT, while the required headers should be copied exactly into the browser request. The application should generate the object key; accepting an arbitrary bucket key from a client turns a storage primitive into a namespace-policy bypass.
The browser flow is straightforward: request authorization, perform a PUT fetch using the returned URL, headers, and file body, then notify the application using the opaque object identifier. That last notification is a hint, not an attestation. Don't mark durable business state complete merely because React says the PUT returned success.
One detail causes disproportionate confusion. The upload request is cross-origin, so the browser may send a CORS preflight before PUT. The documented browser behavior uses an OPTIONS request carrying the intended method and headers; the storage origin must answer with policy that permits the web origin, PUT, and every non-safelisted request header. CORS is browser enforcement, not upload authentication, and making the bucket public does not repair a bad preflight policy.
Build the signing path as a small control plane
The authorization handler should depend on a signer interface, not on a vendor SDK throughout the application. This makes policy testable and keeps a future storage migration from leaking into route handlers. It also makes the operational boundary visible: the signer creates capability URLs, while a metadata store owns application state.
package upload import ( "context" "encoding/json" "net/http" "time" ) type PutGrant struct { ObjectKey string `json:"object_key"` UploadURL string `json:"upload_url"` Method string `json:"method"` RequiredHeader map[string]string `json:"required_headers"` ExpiresAt time.Time `json:"expires_at"` } type Signer interface { SignPut(context.Context, string, string, int64, time.Duration) (PutGrant, error) SignGet(context.Context, string, time.Duration) (string, error) } type Policy interface { AuthorizeUpload(context.Context, string, string, int64) (string, error) } type Handler struct { Signer Signer Policy Policy TTL time.Duration } type authorizeRequest struct { ContentType string `json:"content_type"` Size int64 `json:"size"` } func (h Handler) AuthorizeUpload(w http.ResponseWriter, r *http.Request) { userID, _ := r.Context().Value("user_id").(string) if userID == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var in authorizeRequest dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) dec.DisallowUnknownFields() if err := dec.Decode(&in); err != nil || in.Size <= 0 || in.ContentType == "" { http.Error(w, "invalid upload request", http.StatusBadRequest) return } objectKey, err := h.Policy.AuthorizeUpload(r.Context(), userID, in.ContentType, in.Size) if err != nil { http.Error(w, "upload rejected", http.StatusForbidden) return } grant, err := h.Signer.SignPut(r.Context(), objectKey, in.ContentType, in.Size, h.TTL) if err != nil { http.Error(w, "authorization unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(grant) } The example deliberately leaves signature generation behind Signer. Presigned URL formats, canonicalization rules, and SDK calls are provider-specific; the application-facing behavior does not need to be. In an Express service, the route should enforce the same sequence before calling its chosen signing library. React should remain unaware of which library created the URL.
Expiry needs a capacity-planning answer rather than a fashionable default. Set it long enough for an authorized client to begin the transfer under the slowest network condition you support, but short enough to limit reuse. The correct value depends on file-size limits, connection distribution, retry policy, and the storage service's signature semantics. I'm not sure a universal number exists; a load test with throttled clients and expired grants would resolve it for a specific system.
Capacity, SLOs, and failure modes
Moving bytes away from Express removes application bandwidth from the hot path, but it does not make capacity free. Authorization requests still hit identity, quota, policy, signing, and metadata dependencies. If 20,000 clients reconnect after a frontend release, they can create a signing spike even when only a fraction completes an upload. Capacity plans therefore need separate rates for grants issued, PUTs started, PUTs completed, completion checks, and signed downloads. Those numbers won't be equal.
Define an SLO for the control plane, such as the proportion of valid authorization requests completed within the chosen latency target, then instrument policy rejections separately from dependency failures. Useful dimensions include requested byte range, content-type class, tenant, grant age at first use, and completion lag. Avoid placing full signed URLs in logs: query strings may carry capability material, and high-cardinality object keys make poor metric labels.
A browser-visible failure commonly appears as a generic CORS message even when the useful distinction is elsewhere. Start with the browser network panel: did an OPTIONS preflight occur, did its response allow the exact origin and PUT method, and did it allow the headers React intends to send? Then compare the actual PUT method and headers with the grant contract. A changed Content-Type can turn an apparently identical request into a rejected signed request under schemes that bind headers. The symptom might be a 403, but randomly widening CORS or making the bucket public is the wrong response.
Run that diagnosis in a fixed order because two requests are involved and changing several controls at once destroys the evidence. Consider a React build served from app.example.test that asks Express for a grant containing PUT plus a required Content-Type. In the browser network panel, first inspect OPTIONS: its requested method must be PUT, its requested-header list must include the header the client will send, and the response must permit the exact application origin rather than a similar preview hostname. Only after that exchange is accepted should the actual PUT become the focus. Compare its method and headers byte for byte with the grant contract, then repeat with one deliberate mismatch and confirm rejection. Finally, repeat from a disallowed origin and confirm that the browser does not expose the cross-origin response. This sequence — preflight, exact upload, deliberate mismatch, hostile origin — separates browser policy from signature policy without relying on a vague console message, and it leaves a compact regression case that can run whenever origins or upload headers change.
Measure both.
Retries need policy too. A client may retry the same PUT only if the signing scheme and application state permit it; otherwise it requests a new grant for the same pending object. Use idempotency at the authorization boundary so a lost JSON response does not allocate an unbounded series of keys. The completion transition should also be idempotent.
Short version: retries are state transitions, not a loop around fetch.
This is where private uploads accumulate trash. Clients close laptops, mobile radios disappear, and tabs get killed after authorization. Object lifecycle policies can expire abandoned temporary objects; a scheduled reconciler is the alternative when the storage layer cannot express the required rule. Keep the temporary prefix separate from committed objects so cleanup policy cannot erase valid data.
Verify the deployment and make rollback dull
Test the whole browser boundary in a staging origin. Unit tests for the signer are necessary but cannot prove that preflight policy, signed headers, DNS, browser behavior, and storage responses agree. The verification matrix should include the allowed origin, a disallowed origin, maximum and over-limit sizes, allowed and rejected media types, an expired grant, a modified required header, a duplicate completion call, and a private download attempted without application authorization.
Use a small Go client in CI to verify the grant contract outside the browser, while retaining one real-browser test for CORS. This client intentionally sends only the headers returned by the control plane.
package uploadtest import ( "bytes" "context" "fmt" "io" "net/http" ) type Grant struct { UploadURL string Method string RequiredHeader map[string]string } func Put(ctx context.Context, client *http.Client, grant Grant, body []byte) error { req, err := http.NewRequestWithContext(ctx, grant.Method, grant.UploadURL, bytes.NewReader(body)) if err != nil { return err } for name, value := range grant.RequiredHeader { req.Header.Set(name, value) } resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() io.Copy(io.Discard, resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("upload status %d", resp.StatusCode) } return nil } Roll out behind a server-side capability flag, because the application controls whether it issues a direct-upload grant. During the first stage, compare authorization counts, starts, verified completions, abandoned objects, and p95 completion lag with the previous path. Rollback means stop issuing new grants and return clients to the old upload path; already issued grants should be allowed to expire naturally, while reconciliation continues. Don't delete the temporary prefix during rollback.
The key alert is not raw PUT error rate alone. Alert on a sustained fall in verified-completion ratio, control-plane SLO burn, or growth in unreconciled temporary bytes. A storage service can report successful PUTs while the application never commits the corresponding records, which is an integrity failure even though transport dashboards look healthy.
Buy, build, or keep uploads on the application tier?
The direct pattern is not suitable for every workload. Stick with application-proxied uploads when the server must inspect or transform every byte synchronously before accepting it, when clients cannot use the storage endpoint, or when upload volume is too small to justify a second-origin CORS and reconciliation runbook. For very large or interruption-prone files, a resumable or multipart protocol may be the better design, but it adds session state and cleanup work that this single-PUT example intentionally does not model.
| Choice | On-call surface | Lock-in pressure | Capacity consequence | Prefer when |
|---|---|---|---|---|
| Application-proxied PUT | One public origin, plus bandwidth and timeout tuning | Lower storage-protocol exposure | App instances carry every byte | Mandatory inline inspection or low volume |
| Direct presigned PUT | CORS, signing, verification, and cleanup | Signature and lifecycle behavior need adapters | Control plane scales separately from file bytes | Private objects and meaningful upload traffic |
| Managed upload component | Provider-specific callbacks and limits | Usually higher | More transfer mechanics are outsourced | The team accepts the boundary to reduce protocol ownership |
| Self-hosted resumable gateway | Upgrades and session recovery | Lower commercial dependency, higher operational ownership | Gateway and state store need explicit headroom | Resume behavior is required and the team can own it |
The catch is operational ownership. Direct upload replaces one obvious bottleneck with several quieter consistency boundaries. A team without time to test CORS, reconcile abandoned objects, rotate signing credentials, and rehearse rollback should not pretend the browser-to-storage path is maintenance-free. Managed services reduce some implementation work, while self-hosting offers more control; neither choice cancels on-call load, it moves it.
Private download is a separate authorization decision. The browser asks the application for access to a committed object, the application checks current identity and policy, and only then does the signer return a short-lived GET URL. Do not reuse an upload grant, expose a permanent public URL, or assume that possession of an object key proves authorization. If download policy needs immediate revocation, signed URL lifetime becomes part of the revocation budget; the exact trade-off depends on the storage system's guarantees.
A sound design can be summarized without a product recommendation: private namespace, narrow grants, exact CORS policy, explicit completion verification, lifecycle cleanup, measured control-plane capacity, and a rehearsed fallback.
That's the runbook.


