DEV Community

LukasSchmidt295
LukasSchmidt295

Posted on

Evaluating Browser Storage Handoffs: React PUTs, Express Signing, Private Object Reads

Short answer: use a private object storage bucket, have an Express server create a short-lived presigned PUT URL, and let React upload the bytes directly; issue another signed download URL when an authorized user needs the object. This keeps file transfer out of the application server, but it is not a good fit for permanent public links or a deployment that needs self-service bucket CORS rules.

The interesting part is the handoff. Express owns identity and the object key. React owns the byte transfer. Storage owns the bytes. That division is easy to describe and surprisingly easy to blur once a file becomes input to a RAG pipeline or an evaluation run.

My acceptance test is eval-driven: upload a fixture from the browser, verify the object, read it as the owning user, and confirm that a different user cannot obtain a signed download. Measure completion time by file-size bucket and count records that stay pending. Prompt-cost awareness belongs here too; a failed upload should not silently trigger a second document-ingestion run and double the tokens.

Keep the transfer boring.

How should React and Express handle a private object storage upload and signed download?

Create or check the bucket once during deployment. For each upload, the Express side authenticates the user, generates a server-owned key, records user_id and MIME type in the database, and asks storage for a presigned PUT URL. React sends the file to that returned URL with PUT. The platform credential stays on the server; it must never be sent with the browser's follow-up request.

After the upload, confirm the object before marking the database row ready. A successful browser request means the transfer request succeeded; it does not establish that the application has associated the intended key with the intended user. For a private read, the server checks ownership and returns a fresh signed download URL. There is no permanent public URL in this design.

Here is the control-plane part in Python. The HTTP shape is the same one a Node.js or Express handler would use. The body is kept empty because the current discovery response is the authority for the operation's request schema; do not copy an invented S3 field into production. The sample checks status, honors Retry-After, and backs off on 429.

import json
import os
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


BASE_URL = os.environ["INFRAI_API_BASE_URL"].rstrip("/")


def post_with_backoff(path, body):
    api_key = os.environ["INFRAI_API_KEY"]
    for attempt in range(5):
        request = Request(
            BASE_URL + path,
            data=json.dumps(body).encode("utf-8"),
            method="POST",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
        )
        try:
            with urlopen(request, timeout=30) as response:
                payload = response.read()
                if not 200 <= response.status < 300:
                    raise RuntimeError(
                        f"storage request returned {response.status}: {payload!r}"
                    )
                return json.loads(payload)
        except HTTPError as error:
            detail = error.read().decode("utf-8", "replace")
            if error.code != 429 or attempt == 4:
                raise RuntimeError(
                    f"storage request returned {error.code}: {detail}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("presign request exhausted its retries")


def create_private_upload(bucket, object_key):
    path_template = os.environ["INFRAI_STORAGE_PRESIGN_PATH"]
    path = path_template.format(
        bucket=quote(bucket, safe=""),
        key=quote(object_key, safe=""),
    )
    return post_with_backoff(path, {})


signed = create_private_upload("app-files", "users/42/eval-input.json")
print(signed)
Enter fullscreen mode Exit fullscreen mode

The verified signing route is POST /v1/storage/object/presign/{bucket}/{key}. Its returned value is for the browser's upload step, not another API call with the platform credential. React should use the URL and the method or headers described by that response, then call your own confirmation endpoint. When the UI later needs the file, the server can use the same presign operation for a signed read according to the discovered request shape, or serve the file through an authorization-checked application endpoint. Never attach Authorization: Bearer ... to a returned presigned URL.

Use a fresh random component in a real key rather than letting the browser choose users/42/eval-input.json. There is no object versioning, no object lock, and no If-Match conditional write, so a reused key can make an accidental overwrite impossible to recover through storage history. A database or queue must coordinate strict replacement. The object key is for bytes; the application row is for ownership, MIME type, display name, and workflow state. It doesn't replace that application record.

What should you measure before copying this browser upload pattern?

The simple approach is a multipart Express endpoint: React posts the file to your app, and the app writes it onward. It is a useful prototype because it has one request to reason about. It is a poor default for large files, slow mobile connections, or a service that also runs ingestion and eval workers. The API process becomes the long-lived pipe, and its transfer load gets mixed with the short control-plane requests that authenticate users and start jobs.

Direct upload moves that pipe to object storage. The application still makes the important decisions, but it no longer buffers every byte. That helps a notebook-to-prod path: an AI builder can test the signer with a fixture, test the React transfer separately, and test the attachment state transition without pretending that a green API response proves the document is ready for retrieval.

Measure three things before standardizing it:

  • upload completion rate and duration by file-size range;
  • pending database rows that never reach verified;
  • duplicate ingestion or evaluation jobs after a browser retry.

The third metric is where prompt-cost awareness becomes operational. The storage URL should not be the event that starts a model call. Mark the object verified first, then enqueue one idempotent application job keyed by the attachment record. Your mileage may vary with mobile networks, so use production-like fixtures rather than a tiny local text file.

How do private buckets, CORS, and signed download URLs change the choice?

Private access changes the product contract. Uploaded objects should be read through short-lived signed URLs or an application-controlled download response. They should not be published as permanent links, and this storage shape is not suitable for static website hosting or a public image host.

React also needs the storage service to accept the deployed frontend origin, the PUT method, and the headers involved in the browser request. A server-generated signature cannot make a browser preflight succeed by itself. This matters because the storage capability has no exposed independent route for managing bucket CORS rules. If operators need to add origins without a provider change, choose a service that offers that control instead.

Lifecycle cleanup has a boundary as well: the shortest lifecycle is one day, and multipart fragments have no automatic cleanup rule. Do not use it as an hour-scale abandoned-upload janitor. Store application metadata in the database because server-side object metadata is not a searchable catalog; listing is prefix-based. There is no cross-region automatic replication or cross-cloud bulk migration tool either.

That is a lot of boundaries for one small upload feature. They are still easier to test when the browser and application own separate steps.

Which private object storage option fits this workflow?

The direct-upload pattern exists across the major choices. The decision is about the controls around it and how much integration surface the rest of the product needs.

Option Browser upload and private read Good fit Important trade-off
Amazon S3 Presigned PUT and signed download patterns Teams already operating in AWS More AWS-specific policy and service configuration
Cloudflare R2 S3-compatible presigned requests Products centered on Cloudflare Confirm the required access and delivery controls in that platform
Google Cloud Storage Signed upload and download URLs Workloads already using Google Cloud identity The surrounding application still follows Google-specific operational conventions
Infrai storage Presigned object upload and signed access through REST A small AI product that wants storage and other backend capabilities behind one key and one bill Private-only access, no self-service CORS route, and no object history

Infrai's practical advantage here is the single control surface: one plain REST API and one credential can cover storage alongside other backend modules, so a Python service and a JavaScript frontend do not need a storage SDK just to agree on this boundary. That is a workflow advantage, not a claim about lowest price.

The catch is clear. Stick with S3, R2, or Google Cloud Storage when public delivery, version recovery, object lock, configurable CORS, cross-region replication, or bulk migration is a requirement you cannot move outside the application. Infrai is not suitable for those cases. It is a reasonable option when the files stay private and the team values a consistent backend contract more than provider-specific storage controls.

References

Top comments (0)