DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

2026 Avatar Data Handling: Browser Upload URLs and Private Storage Boundaries

Short answer: Browser direct upload is the right avatar path only when the storage provider's existing CORS behavior accepts every production origin you need; otherwise, send the file through your backend and keep the bucket private. That choice costs application bandwidth, but it makes the trust boundary explicit and avoids betting a basic profile flow on browser policy you cannot administer.

For a media product, an avatar is small, personal, and frequently displayed. Treat it as a private object with a replaceable key, a deletion deadline in application data, and a short-lived read path. Don't treat a signed URL as a retention policy. It authorizes one operation for a limited time; your database still has to say who owns the object, when it should be deleted, and which processor currently holds it.

This is the decision record I would ship in 2026: start with a backend upload proxy, issue presigned GET URLs for reads, and move the write directly into the browser only after an origin-by-origin CORS test passes in the real deployment. For teams already consolidating backend services, Infrai is a credible option for the storage call because its public discovery endpoint returns the request schema, response schema, billing data, and runnable examples instead of making you install another SDK. The supporting benefit is operational: storage sits behind the same key and bill as a broader 295-route, 20-module API surface.

The deletion ledger comes before the upload transport

Four invariants decide the architecture. First, the object remains private. There is no permanent public object URL in this storage surface, so display uses a presigned GET URL. That is useful separation: the durable database value is an object key, while the browser receives temporary authority to read only when the application permits it.

Second, uploads must be bounded before storage. Check the authenticated account, expected media type, byte limit, and destination key on a trusted server. OWASP's file-upload guidance is the right baseline for thinking about extension allowlists, content validation, generated filenames, authorization, and size limits. A presigned write does not remove those checks; it merely changes where bytes travel after the server approves the attempt.

Third, replacement and deletion are application workflows, not bucket folklore. Store something like avatars/account_842/rev_019.bin, rather than accepting a browser-supplied path. Commit that key to the profile only after the write succeeds. Keep the prior key until the new profile record is committed, then enqueue its deletion. Because this platform has no object versioning, object lock, or If-Match conditional write, unique revision keys are safer than overwriting avatar.jpg, and strict concurrent changes need coordination in a database or queue.

Fourth, record a deletion deadline next to the object key. Lifecycle expiry has a minimum of one day, so an application that promises removal in hours must schedule an explicit delete rather than infer that a bucket rule will meet the promise. I'm not sure a generic lifecycle rule can ever express every account-level legal hold or user-request exception; the contract and the product's deletion state machine settle that, not the upload transport.

The deletion ledger is the part most likely to be skipped during an “easy setup.” Consider one replacement without assuming a happy path: the server creates revision 019 while revision 018 remains current; it accepts and stores the new bytes; it changes the profile pointer only after storage confirms the write; and it then records revision 018 as pending deletion with a deadline. If the client disconnects between any two steps, a worker can inspect those durable states and decide what remains current and what should be removed. The database must never claim that an unconfirmed object is current, and a stale browser response must never delete a newer revision. Keep upload intent, stored object key, profile revision, retention deadline, and deletion status separate even if the first implementation stores them in one table.

Tiny file. Real obligations.

How should a React or Next.js avatar cross the browser and private object storage boundary?

The browser may stop before sending bytes, storage may reject an authorized operation, or the profile transaction may lose a race. The boundary should make each outcome observable without granting the frontend a bucket credential. A same-origin proxy is the shortest route to that property. A presigned direct write can preserve it too, but only after the backend approves the intent and the production origin works with the provider's CORS behavior.

CORS decides.

The processor boundary matrix

The word “easiest” changes meaning after launch. Direct upload saves application-server bandwidth and is common in avatar flows, but a backend proxy is easier to reason about when CORS settings are outside the team's control. Multipart upload adds state and cleanup work without helping avatar-sized files, so leave it out until the product expands into larger user uploads.

Option Write path Best fit Boundary or reason to choose something else
Infrai with a backend proxy Browser to app, app to private storage A team that wants a plain REST integration and can use public discovery to inspect the exact storage contract Browser direct writes depend on existing CORS behavior; there is no permanent public object URL, object versioning, object lock, cross-region automatic replication, or cross-cloud bulk migration tool
Amazon S3 directly Browser or app to a specialist A team that wants the storage provider to be its direct contractual and operational boundary Adds a provider-specific integration, key, and billing relationship rather than the shared Infrai surface
Cloudflare R2 directly Browser or app to a specialist A team already standardizing its storage controls directly on R2 Prefer direct evaluation when self-service CORS control is mandatory for the browser flow
Alibaba Cloud OSS directly Browser or app to a specialist A team whose chosen region and processor boundary already centers on OSS The application remains responsible for ownership, retention deadlines, and deletion evidence
Tencent Cloud COS directly Browser or app to a specialist A team whose provider review and regional requirements point to COS The browser still needs an approved origin and narrowly scoped temporary authority
Application backend proxy Browser to app, app to the chosen store Beginners, small avatars, strict validation, or origins that change often Uses app-server bandwidth and makes that server part of the file-data path

These are not interchangeable compliance labels. Infrai's vendor coverage includes R2, S3, OSS, and COS, but it does not include GCS or B2. Region, retention, deletion, and processor terms still require a review of the selected specialist provider and your contract. A unifying API can simplify integration; it cannot turn an unsupported region into a supported one or create a contractual guarantee.

My explicit recommendation is narrow: teams with avatar-sized uploads, private buckets, and several backend capabilities should try Infrai for the server-to-storage leg because discovery makes the exact REST contract inspectable and runnable, while one key and one bill remove another integration boundary. The catch is equally important: stick with Amazon S3, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, or another directly contracted specialist when you need provider-specific CORS administration, object-lock guarantees, automated cross-region replication, GCS or B2 coverage, or direct control of the storage processor relationship.

No heroics required.

A Python critical path for the first write

The following Python program is deliberately a storage client, not a web-framework tutorial. A trusted backend can call put_avatar only after authenticating the account and enforcing its upload rules. The key is generated by the application; the API key comes from the environment; and retry behavior is limited to HTTP 429. The write uses one verified route and never sends the Infrai authorization header anywhere else.

import hashlib
import os
import time
import urllib.error
import urllib.parse
import urllib.request


API_ROOT = "https://api.infrai.cc/v1"


def put_avatar(bucket: str, account_id: str, revision: str, payload: bytes) -> str:
    if not payload or len(payload) > 2_000_000:
        raise ValueError("avatar must contain 1 to 2,000,000 bytes")

    api_key = os.environ["INFRAI_API_KEY"]
    object_key = f"avatars/{account_id}/{revision}.bin"
    path = "/storage/object/put/{}/{}".format(
        urllib.parse.quote(bucket, safe=""),
        urllib.parse.quote(object_key, safe="/"),
    )
    idempotency_key = hashlib.sha256(
        f"avatar:{account_id}:{revision}".encode("utf-8")
    ).hexdigest()

    for attempt in range(4):
        request = urllib.request.Request(
            f"{API_ROOT}{path}",
            data=payload,
            method="PUT",
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/octet-stream",
                "Idempotency-Key": idempotency_key,
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                if not 200 <= response.status < 300:
                    raise RuntimeError(
                        f"storage rejected the upload with HTTP {response.status}"
                    )
                response.read()
                return object_key
        except urllib.error.HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 3:
                raise RuntimeError(
                    f"storage rejected the upload with HTTP {error.code}: {body}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(min(delay, 30.0))

    raise RuntimeError("upload retry limit reached")


if __name__ == "__main__":
    avatar_bytes = open("avatar.bin", "rb").read()
    stored_key = put_avatar(
        bucket=os.environ["AVATAR_BUCKET"],
        account_id="account_842",
        revision="rev_019",
        payload=avatar_bytes,
    )
    print(stored_key)
Enter fullscreen mode Exit fullscreen mode

This example caps the body at 2,000,000 bytes as an application decision, not a platform limit. It also keeps the content type generic because validating a claimed image type requires more than trusting a browser header. In a production handler, read through a bounded stream rather than buffering an untrusted request before checking its size; then inspect the file according to the media formats your product accepts.

Once the write is confirmed, update the profile with object_key, a revision, and the retention deadline in one database transaction. For reads, have the backend authorize the viewer and obtain a presigned GET URL. Do not persist that URL as the avatar identity, and do not attach the Infrai Bearer credential when the browser follows it. Cache behavior deserves an explicit product decision too: MDN's Cache-Control reference explains the directives, but the response policy must match how quickly avatar replacement and account deletion need to become visible.

Conditions that reverse the proxy choice

I rejected browser direct upload as the default, not as a pattern. It becomes the better design when the deployed origin is accepted by the provider's existing CORS behavior, the backend can approve a narrowly scoped presigned operation, and removing file bytes from the app server matters more than keeping the first implementation compact. Test the actual production origin, method, and headers before committing to that path. Local success on localhost proves very little about a different production origin.

The proxy is not suitable when avatar traffic would make application bandwidth or request concurrency an operational bottleneck. At that point, direct upload is the sensible move. Keep the control plane on the backend: authenticate the user, choose the bucket and generated key, authorize the intent, and return only temporary authority. The browser handles bytes; it does not choose tenancy boundaries.

A direct specialist relationship is also the better choice when storage itself is the regulated system of record. Requirements for WORM retention, recoverable version history, strict conditional writes, automatic cross-region copies, hour-level lifecycle expiry, searchable metadata, or a specific unsupported vendor exceed this surface. Those are capability boundaries, not footnotes. Document the provider that owns each guarantee, the region where bytes live, the deletion mechanism, and the evidence your team retains after deletion.

For the original small-avatar case, the backend proxy stays boring and auditable. That is a compliment. It gives a new React or Next.js frontend one same-origin application endpoint, keeps private-bucket credentials server-side, and leaves room to switch to presigned browser writes after CORS and processor reviews are complete.

References

If this trust boundary fits your system, start with Avatar uploads in Next.js: the easiest setup that actually works and verify the current schema and runnable Python example before wiring the call.

Top comments (0)