DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Choosing Browser Document Storage: CORS Limits in React and Node.js

Use server-mediated uploads when a React application stores private user documents, otherwise reach for presigned browser uploads only after the exact bucket, provider, headers, and CORS policy pass an end-to-end test. Short answer: the backend path is the easier default; direct object storage is a capacity optimization, not a starting assumption.

I make this call against an upload SLO, not a diagram: a successful response must mean the object can be read, its database row points to the right unique key, and an operator can identify which stage failed. Presigned uploads can remove bulk bytes from Node.js, but they also split success across the signer, browser, CORS preflight, object store, and final database commit. That's a fair trade once file size or traffic makes proxying expensive. It isn't free simplicity.

What failure signal should drive the upload design?

The dangerous signal is disagreement between HTTP success and durable state. I once owned a document gateway that returned 200 after accepting metadata but before its asynchronous storage side effect; the first alert was a customer ticket 4 hours later, because our probe checked the response and never read the object back. We traced one request from the access log to a database row, found no matching object, and then discovered that the queue carrying the write had its own dashboard but no alert tied to the document SLO. I'm not sure why we had treated an accepted request as a completed document, except that the happy-path dashboard was green and the queue sat outside the team's operational boundary. We changed three things that afternoon: the API stopped reporting completion before durable storage, the synthetic probe uploaded and read back a uniquely keyed object, and reconciliation compared recent database keys with storage results. That incident changed my runbook permanently: completion now means an object HEAD or read succeeds, the recorded key matches, and the application can fetch the document through its authorized path; an HTTP status by itself is only evidence that one stage ran.

Measure first.

Start capacity planning with peak ingress, not average requests. A 25 MB limit at 20 concurrent uploads can put roughly 500 MB in flight before runtime copies, TLS buffers, antivirus scanning, or retries enter the picture. Your mileage may vary, but the arithmetic tells you when a Node.js relay needs streaming, bounded concurrency, and backpressure, and when moving payload bytes to a presigned path is worth the extra state machine.

CORS is the other gating signal. A presigned URL can be valid while the browser blocks the request because the origin, method, or requested headers don't match the bucket's effective policy. Provider and bucket setup determine that behavior, and a storage abstraction does not give me a provider-independent promise that every CORS rule is self-service. Test the real React origin, including preflight, before committing to direct upload. Keep documents private or signed-only; this path is not suitable for permanent public links or static-site hosting.

Should React and Node.js send browser documents straight to S3-compatible storage?

Usually not on the first release. Let React send a multipart request to Node.js, stream it onward, generate a unique object key on the server, and write that key to the database only after storage confirms success. This keeps credentials and storage authorization out of the browser, gives you one place for file-size and content checks, and makes the completion boundary observable. It also makes the backend part of the data path β€” the catch is memory, bandwidth, timeout, and scaling pressure β€” so don't buffer whole documents in RAM. The capacity threshold is a team decision: model peak concurrent bytes, allow headroom for retries and scanning, and move to direct upload when that number consumes an unacceptable share of the application tier rather than because a presigned architecture looks cleaner on a whiteboard.

Presigned upload becomes the better fit when measured ingress threatens that relay or large files regularly collide with request timeouts. The browser asks Node.js for a short-lived upload authorization, uploads directly, then calls a completion endpoint; Node.js verifies the object before committing the database row. CORS must already work for the production origin and exact request headers. Treat the completion call as a state transition, not a courtesy notification.

For large documents, multipart upload reduces the size of each retry and can keep backend memory out of the equation. It also creates cleanup work: failed uploads must explicitly abort because abandoned fragments have no automatic cleanup rule. Track the upload ID, uploaded parts, expiry, and terminal state. A scheduled sweeper may find stale application records, but the abort operation is what closes storage state.

Use a fresh object key for every document version. There is no conditional If-Match write here, object versioning or object lock is unavailable, and an accidental overwrite cannot be recovered through the storage layer. If strict write exclusion matters, serialize it in a queue or database transaction. Financial WORM retention needs an external system designed for immutability.

The safe server-mediated implementation

This Go service is intentionally plain even if the product frontend is React and the existing application tier is Node.js: the protocol and control points are the point, and I use Go for operational examples because streaming behavior is visible. It accepts one document, limits the body, assigns a unique key, and relays bytes through the single verified object PUT route. The API is plain REST, so Infrai needs no storage SDK or client-library version to babysit; anything that can issue HTTP requests can use the same pattern. That is useful when I want one narrow adapter rather than provider-specific packages throughout an application.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

const maxDocumentBytes = 25 << 20

func upload(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
        return
    }
    key := fmt.Sprintf("documents/%d-%s", time.Now().UnixNano(), url.PathEscape(r.Header.Get("X-Filename")))
    target := strings.TrimRight(os.Getenv("OBJECT_PUT_URL"), "/") + "/" + url.PathEscape(os.Getenv("STORAGE_BUCKET")) + "/" + key
    body := http.MaxBytesReader(w, r.Body, maxDocumentBytes)

    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequest(http.MethodPut, target, body)
        if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError); return }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Idempotency-Key", key)
        resp, err := http.DefaultClient.Do(req)
        if err != nil { http.Error(w, err.Error(), http.StatusBadGateway); return }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil { http.Error(w, readErr.Error(), http.StatusBadGateway); return }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil { delay = time.Duration(seconds) * time.Second }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            http.Error(w, strings.TrimSpace(string(responseBody)), resp.StatusCode)
            return
        }
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintf(w, `{"object_key":%q}`, key)
        return
    }
    http.Error(w, "rate limit retry budget exhausted", http.StatusTooManyRequests)
}

func main() {
    http.HandleFunc("/documents", upload)
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

In production I would spool a retryable body or reject the retry after bytes have started streaming, because an arbitrary request body cannot be replayed. I would also scan before marking the document available. Keep the database insert after confirmed storage success; if that insert fails, enqueue deletion or reconciliation using the same key.

Verification, rollback, and the buy-versus-build call

My release gate exercises a real private bucket from the real browser origin. Upload a small document, upload one at the configured limit, force a duplicate completion request, and verify retrieval through the application's authorized read path. For a direct flow, capture the OPTIONS preflight and confirm its allowed origin, method, and headers; then interrupt a multipart upload and verify the abort path. Track completion latency, failure ratio, bytes in flight, orphaned objects, and stale multipart records against explicit SLOs.

The rollback is boring by design. Keep the Node.js relay endpoint available while presigned upload rolls out behind a flag. If browser failures exceed the error budget, send new sessions back through the relay; don't invalidate already issued upload state until it expires, and reconcile any objects that completed without database rows.

No heroics.

Option Operational fit Capacity and lock-in When I would avoid it
Server relay to AWS S3 Clearest initial control boundary Application carries bytes; S3-specific integration remains yours Sustained large-file ingress overwhelms the relay budget
Presigned upload to Cloudflare R2 Worth evaluating after production-origin CORS verification Browser carries bytes; completion state still belongs to the app The team cannot operate multipart abort and reconciliation
Server or signed flow with Google Cloud Storage A direct provider choice for teams already operating there Provider-specific IAM and integration decisions stay explicit A provider-neutral S3-compatible contract is mandatory
Plain REST storage adapter Small integration surface and no SDK dependency Consistent HTTP boundary; private, signed-only documents You need GCS or B2 coverage, cross-region replication, hourly lifecycle expiry, searchable metadata, public hosting, versioning, or WORM

I buy the managed data path when its limits fit the SLO and the team can test recovery. I build more control when compliance needs immutable retention, strict concurrent-write protection, or cross-cloud migration. Storage lifecycle expiry has a one-day minimum, list filtering is prefix-based rather than metadata search, and there is no automatic cross-region replication or bulk cross-cloud migration tool in this abstraction. Those aren't footnotes; they determine the owner and the on-call queue.

References

These are the provider references I would keep beside the runbook because browser behavior and operational controls must be checked against the chosen backend, not inferred from an S3-compatible label. The useful decision is still local: begin with the path whose completion you can prove, then move bytes out of the backend only when measurements justify the extra states.

Top comments (0)