DEV Community

CelesteRaine1783
CelesteRaine1783

Posted on

React Upload ADR: Presigned S3-Compatible Object Storage or a Node.js Relay?

Private documents change the decision: an upload is incomplete until authorization, object naming, failure cleanup, and database state agree. Short answer: start with a Node.js backend relay for ordinary user documents; move the byte stream to presigned browser uploads only after the exact S3-compatible provider and bucket pass a CORS test, and use multipart only when file size makes the extra state worthwhile.

This is an architecture decision, not a contest to minimize request count. The backend relay has an obvious cost — application bandwidth and a longer data path — but it keeps the first implementation inside one security boundary. A signed URL removes that hop while making the browser, CORS policy, signature lifetime, and storage state part of the transaction.

Start there.

What invariants should govern browser document upload to S3-compatible object storage?

The first invariant is ownership: the application chooses a fresh object key, binds it to the authenticated user, and records that key rather than a permanent public URL. A UUID-based key prevents two uploads from silently targeting the same object; that matters when conditional If-Match writes aren't available. It also keeps a filename such as tax-return.pdf out of the authorization model.

The second invariant is privacy. Objects remain private or signed-only, and the application grants time-bounded access after checking the caller again. This rules out a public-read shortcut. It also means this design is not suitable for static-site hosting, a public image host, or any product that requires permanent anonymous links.

Third, database state must follow confirmed storage state. With a server relay, write the object, inspect the response, and only then commit its key to the document row. With a presigned flow, the browser's successful PUT is not enough evidence for the application: require a completion callback and verify the object before changing the row from pending to ready. The supplied evidence does not establish one cross-provider consistency guarantee, so I would test read-after-write and metadata behavior against the selected provider before making a stronger promise.

Keep the failure boundary explicit. A rejected authorization creates no upload. A failed single-part write creates no ready document record. A failed multipart session is aborted explicitly; abandoned fragments have no automatic cleanup rule here. No hand-waving.

Decision table

The provider choice and the transfer-path choice are related, but they aren't the same decision. AWS S3, Cloudflare R2, and Google Cloud Storage are direct provider integrations; Infrai is a plain REST surface spanning storage and other backend modules under one key and contract. That breadth can remove later SDK and credential integrations, but it doesn't remove the need to test the chosen storage vendor's browser behavior.

Option Best fit Main operational boundary Decision here
AWS S3 direct integration A team that wants to own an S3-specific integration The application owns provider credentials, signing, CORS setup, and storage-specific operations Strong choice when S3 itself is the long-term platform commitment
Cloudflare R2 direct integration A team already standardizing on R2 Browser behavior still depends on the configured bucket and signed-request flow Prefer when R2-specific operations and account ownership are desirable
Google Cloud Storage direct integration A team committed to Google Cloud It is a separate provider contract rather than the S3-compatible path considered here Prefer when the rest of the data plane is already on Google Cloud
Infrai REST integration A team expecting several backend capabilities behind one consistent API Storage covers R2, S3, OSS, and COS, not GCS or B2; browser CORS must still be verified A reasonable consolidation choice, not a reason to force direct browser transfer

I'm not sure which direct provider will be easiest in a given organization's account because that depends on controls outside the application repository. Your mileage may vary. The useful test is concrete: run a preflight and a signed PUT from the real origin, with the real headers and file-size range, before approving the browser path.

Critical path: relay the first version

The smallest defensible first version sends the file to the authenticated backend, generates a unique key there, and streams the body onward. The Python program below shows the storage half of that critical path with an explicit method, bounded exponential retry for 429, Retry-After support, and error bodies surfaced to the caller. It intentionally accepts one file at a time; the surrounding Node.js application can apply its session and document-row rules before invoking the same HTTP contract.

import os
import sys
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


def put_private_document(bucket: str, path: Path) -> str:
    key = f"documents/{uuid.uuid4()}"
    encoded_bucket = quote(bucket, safe="")
    encoded_key = quote(key, safe="/")
    api_base = os.environ["STORAGE_API_BASE"].rstrip("/")
    route = "/v1/storage/object/put/{bucket}/{key}"
    url = f"{api_base}{route.format(bucket=encoded_bucket, key=encoded_key)}"
    body = path.read_bytes()

    for attempt in range(5):
        request = Request(
            url,
            data=body,
            method="PUT",
            headers={
                "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
                "Content-Type": "application/octet-stream",
            },
        )
        try:
            with urlopen(request, timeout=60) as response:
                response.read()
                return key
        except HTTPError as error:
            detail = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(f"storage request failed: {error.code} {detail}")
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("retry limit reached")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        raise SystemExit("usage: upload.py BUCKET FILE")
    print(put_private_document(sys.argv[1], Path(sys.argv[2])))
Enter fullscreen mode Exit fullscreen mode

The bearer credential stays on the server. If the architecture later returns a presigned URL to React, the browser sends the file to that returned URL without the Infrai Authorization header. Those are different trust domains — mixing their headers is both unnecessary and dangerous.

For genuinely large documents, create a multipart upload, presign individual parts, track every returned part result, and complete only after all parts succeed. On cancellation or terminal failure, call abort explicitly. Multipart reduces backend memory pressure when the browser path works, but it introduces a small state machine with pending, uploading, completing, ready, aborted, and expired application states; pretending it is merely a faster PUT is how fragments and orphaned rows accumulate.

Abort means abort.

Rejected option, and when to reverse the decision

The initial decision rejects direct-to-storage presigned uploads because “easiest” should include diagnosis and recovery, not just the happy-path diagram. A browser preflight can fail before any document bytes move, signature expiry can race a slow client, and multipart completion can outlive the page that initiated it. The server relay gives the backend one place to enforce size, ownership, content policy, and the order of storage and database writes.

The catch is bandwidth. Don't keep the relay by habit when large-file traffic makes that extra hop a measured constraint. Reverse the decision when tests from every supported origin prove the CORS contract, the application can verify completion before exposing a document, and an abort path is exercised for failed multipart sessions. At that point React can upload through a short-lived presigned URL while Node.js remains the control plane.

Stick with a direct AWS S3, R2, or Google Cloud Storage integration when provider-native controls are the real requirement. Infrai is also a poor fit when the design requires GCS or B2 coverage, public-read objects, object versioning or object lock, cross-region automatic replication, cross-cloud bulk migration, hourly lifecycle expiry, or server-side metadata search. Strict write exclusion needs a queue or database coordinator because conditional writes aren't part of this contract; regulated WORM retention needs an external solution.

That limitation list matters more than a polished upload demo. The right design is the one whose failure modes the team can observe, reconcile, and explain six months later.

References

Top comments (0)