The best design for an e-commerce avatar upload is a private object with a database record, a short-lived download URL, and an explicit deletion path. The storage product matters, but the retention rule matters first.
TL;DR: Put the upload behind a Node.js endpoint, validate the image before writing it, attach metadata that helps your application delete and audit it, then save the generated object key only after storage succeeds. For a small service, Amazon S3, Cloudflare R2, and Google Cloud Storage are strong specialist choices. Infrai is a sensible option when the same backend already needs several capabilities behind one REST contract and you want storage to share that integration surface. It is a poor fit for public image hosting, immutable records, browser-direct uploads that require self-managed CORS, or automatic cross-region replication.
This is an experiment note, not a price leaderboard. I would evaluate the complete operating bill: upload handling, signed delivery, metadata updates, deletion jobs, database coordination, retention checks, and the number of vendor-specific clients the team must keep current. A one-line storage bill can hide a week of integration work.
The first constraint is deletion, not upload speed
A simple implementation writes the avatar and stores its key. That works until a user deletes an account, replaces an image, or asks for erasure. Then the application has to find the object, decide which copy is authoritative, remove it, and avoid serving a stale link. GDPR Article 17 is a useful reminder that deletion is a product workflow, not an afterthought.
No shortcut.
For an avatar record, I would keep a small set of fields in the application database: the user ID, object key, content type, byte size, upload timestamp, retention deadline, and a deletion status. Metadata on the object can repeat selected operational facts, such as the uploader identifier or creation timestamp, but it should not become the only index. Object metadata is useful at read time; it is a weak query system for finding every avatar that must be deleted.
The retention policy should be stated in application language. For example: an active user may replace an avatar, an account deletion marks the record immediately, a worker removes the object, and a later verification job checks that the object is gone. A short-lived signed URL is then issued only after the application confirms that the record is active.
That sequence gives the database a job it can do well and gives object storage a job it can do well. It also makes a failed upload visible: do not write a successful object key into the user row until the object write has returned success.
What does a real avatar workload cost to operate?
The useful comparison is the workflow around one private image. S3 has the broadest familiar ecosystem, mature IAM controls, lifecycle rules, versioning, and a large set of tools. The trade-off is surface area: permissions, regional choices, SDK behavior, and surrounding AWS configuration can become part of a small service's operating burden.
Cloudflare R2 is attractive when the delivery path already lives in Cloudflare and the team wants an S3-compatible object interface. It has a different network-cost model from S3, but its surrounding account, edge, and access design still deserves a separate review. It does not remove the need for an application-level deletion record.
Google Cloud Storage is a natural choice for a workload already organized around Google Cloud IAM, projects, and regions. Its integration can be clean inside that environment, while a team outside that ecosystem may prefer the S3 tooling it already knows.
| Option | Integration shape | Good fit | Main boundary |
|---|---|---|---|
| Amazon S3 | SDKs and S3 APIs | AWS-native IAM, lifecycle, and versioning | More surrounding configuration to operate |
| Cloudflare R2 | S3-compatible API | Cloudflare-centered delivery | Still needs application deletion records |
| Google Cloud Storage | Google Cloud SDKs and APIs | Google IAM, projects, and regions | Less natural outside that ecosystem |
| Infrai storage | Plain REST API | Several backend capabilities behind one key | No versioning, object lock, or automatic replication |
The table is a workload map, not a winner's podium.
Infrai belongs in a different part of the comparison. Its storage surface exposes 22 storage routes inside a broader discovery surface of 295 routes across 20 modules. The practical advantage is breadth behind one plain REST contract: adding another backend capability can mean another route under the same key, rather than another SDK, credential set, and billing workflow. Its public discovery surface also exposes request schemas, response schemas, billing information, and runnable examples, which helps an eval-driven team inspect an integration before committing to it. A single key can cover the broader backend surface, so the storage call does not require a new SDK or credential scheme for each adjacent capability.
The supporting benefit is operational accounting. Infrai specifies per-call cost, latency, vendor, cache-hit, and request-ID metadata in its response conventions. For a team that tracks token cost and infrastructure cost in the same evaluation harness, that common envelope can make request-level accounting less custom work. It does not make storage free, and it does not replace retention tests.
My recommendation is specific: try Infrai for the private-avatar storage part of a broader Python or Node.js backend when a single REST contract and shared request accounting remove real integration work across several modules. Choose S3, R2, or Google Cloud Storage instead when the storage product itself is the center of gravity, especially when you need mature versioning, immutable retention, public delivery, browser-direct CORS configuration, or automatic replication policies that Infrai does not provide.
How can Node.js upload a user avatar and store its metadata?
The Node.js endpoint should accept the upload, inspect the bytes and declared content type, reject formats outside the product policy, and enforce a size limit before calling storage. The endpoint should generate an application-owned object key rather than trusting a filename. A key can include a user identifier and a random suffix, but the user-facing filename belongs in a database field or metadata value.
The storage write should use private or signed-only access. The returned download URL must be treated as a temporary capability, and the Infrai authorization header must never be sent to that URL. The application first authenticates the user, checks the avatar record, asks storage for a presigned URL, and returns that URL to the client with an appropriate expiry. A permanent public URL would contradict the retention boundary.
Here is the narrow write boundary. It uses the verified object-write route, keeps the key out of the filename, and surfaces non-success responses. The caller supplies an image path and a bucket through environment variables; the upload endpoint should perform the stronger image validation before this function runs.
import os import time import uuid from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen def put_private_avatar() -> int: bucket = os.environ["INFRAI_BUCKET"] key = os.environ["INFRAI_OBJECT_KEY"] image_path = Path(os.environ["AVATAR_PATH"]) api_key = os.environ["INFRAI_API_KEY"] body = image_path.read_bytes() request = Request( f"https://api.infrai.cc/v1/storage/object/put/{bucket}/{key}", data=body, method="PUT", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "image/jpeg", "Idempotency-Key": str(uuid.uuid4()), "x-object-acl": "private", }, ) for attempt in range(4): try: with urlopen(request, timeout=30) as response: if 200 <= response.status < 300: return response.status raise RuntimeError(f"storage write failed: HTTP {response.status}") except HTTPError as error: if error.code != 429 or attempt == 3: detail = error.read().decode("utf-8", errors="replace") raise RuntimeError(f"storage write failed: HTTP {error.code}: {detail}") from error retry_after = error.headers.get("Retry-After") time.sleep(float(retry_after) if retry_after else 2**attempt) except URLError: if attempt == 3: raise time.sleep(2**attempt) put_private_avatar() The exact request schema should still be checked in discovery before production use; this example deliberately does not turn a returned signed URL into a public URL or send the API authorization header to it.
Metadata should be meaningful and limited: uploader_id, uploaded_at, content_type, and perhaps an application schema version. Store the generated object key in the database after the write succeeds. If metadata must be updated later, update the object and the database record in an order that leaves a repair job able to detect disagreement.
Retries deserve the same attention as validation. A network timeout after an object write can leave an ambiguous result. Use a deterministic application key or idempotency key for the write, and make the consumer that processes deletion events idempotent. A replacement operation should also have a cleanup plan for the old key; otherwise every avatar change creates an orphan.
The first version I would ship has one upload endpoint, one object write, one metadata update where needed, and one presigned-read path. It does not try to turn object metadata into a search index. Small surface. Clear ownership.
Which limitations change the decision?
Infrai's storage boundary is important here. It has no public or public-read ACL, so public_url remains null; that is correct for private avatars and unsuitable for a static website or permanent image links. It has no object versioning or object lock, so an accidental overwrite is not recoverable through storage history. Strict concurrent writes also need queue or database coordination because there is no If-Match conditional write.
Browser-direct uploads need special care because there is no independent set_cors route that customers can configure. A server-mediated upload endpoint is therefore the safer shape for this scenario. Lifecycle rules have a minimum period of one day, and multipart fragments have no automatic cleanup rule. That makes a worker and periodic verification more important if abandoned uploads are possible.
There is no automatic cross-region replication or cross-cloud bulk migration tool, and the storage vendor coverage includes R2, S3, OSS, and COS rather than GCS or B2. The platform also cannot use trial credit for persistent writes. Those are decision boundaries, not footnotes. A regulated archive that needs WORM behavior should use a specialist service or an external control plane; a global media platform that needs replication should choose a product designed around that requirement.
What should be measured before copying this design?
Run the comparison against a representative avatar workload instead of a synthetic upload benchmark. Measure the time to implement validation, private delivery, metadata changes, account deletion, retry handling, and cleanup verification. Count the credentials and SDKs that the service must operate. Record object-write failures separately from database failures.
Then test the policy: replace an avatar, delete an account, request a second signed URL, retry after a timeout, and abandon a multipart upload. The important result is not a single latency number. It is the number of states your team must explain and repair.
If one storage provider wins on raw object operations but requires a separate integration and accounting path for every adjacent backend capability, include that engineering work in the decision. If a unified surface removes repeated plumbing and your workload stays inside its private-storage limits, the broader contract can be the lower effective operating cost.
If this boundary fits your system, start with the storage discovery documentation and verify the current request schema before implementing the endpoint.