DEV Community

JethroRhodes8268
JethroRhodes8268

Posted on

Node.js Avatar Uploads and Retention — Choosing Storage for Expiring Links

Short answer: choose a developer-friendly object store with a good Node.js SDK, then make retention a first-class application rule. For an e-commerce avatar upload, the easiest option is the one that gets a private object into storage, creates an expiring download link, and deletes old objects without a second control plane. The storage brand matters less than that contract.

The constraint that changes the choice is deletion. An avatar is not an archive. It is a user-owned object with a short business lifetime, a private default, and a replacement path. I want the upload key to be deterministic, the download URL to be temporary, and the delete job to be observable. A storage service that is pleasant for public image hosting can still be the wrong tool here.

How should a Node.js-based user choose cloud storage for avatar uploads?

Keep the browser away from permanent credentials. The application authenticates the user, chooses an object key, asks storage for a pre-signed upload or download operation, and records the object key plus its retention deadline in the database. The database owns the decision to delete; storage owns the bytes.

This small TypeScript example uploads the object through Infrai's verified storage route. The base URL stays in configuration, so credentials never enter source. It is deliberately boring: one object, one explicit method, one idempotency key.

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;

if (!baseUrl || !apiKey) {
  throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
}

export async function putAvatar(
  bucket: string,
  userId: string,
  contentType: string,
  bytes: Uint8Array,
): Promise<{ objectKey: string; deleteAfter: Date }> {
  if (!contentType.startsWith("image/")) {
    throw new Error("Avatar content must be an image");
  }

  const objectKey = `avatars/${userId}/current`;
  const deleteAfter = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
  const objectPutPath = "/v1/storage/object/put/{bucket}/{key}";
  const endpoint = `${baseUrl}${objectPutPath
    .replace("{bucket}", encodeURIComponent(bucket))
    .replace("{key}", encodeURIComponent(objectKey))}`;
  const idempotencyKey = `avatar:${userId}:${objectKey}`;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(endpoint, {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": contentType,
        "Idempotency-Key": idempotencyKey,
      },
      body: bytes,
    });

    if (response.ok) {
      return { objectKey, deleteAfter };
    }

    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Avatar upload failed (${response.status}): ${await response.text()}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error("Avatar upload retry loop ended unexpectedly");
}
Enter fullscreen mode Exit fullscreen mode

The numbers here are application policy, not a storage promise: a 15-minute upload window, a five-minute read URL, and a 30-day retention deadline. Change them to match the product. The important detail is that the URL lifetime and object lifetime are separate. A short URL does not delete the object.

A replacement needs one more decision. With a stable key such as avatars/{userId}/current, a successful new upload overwrites the previous object. That is easy to read and easy to clean up, but it cannot recover an accidental overwrite when versioning is absent. A random key plus a database pointer preserves the old object until a cleanup worker confirms the new one. I prefer the second model once avatar replacement is business-critical. The extra row is worth it when a profile edit can be retried, a mobile client can reconnect halfway through an upload, or an image moderation step can reject the new file after the old avatar has already been replaced. In that design, the database marks one object as current only after the upload succeeds; the cleanup worker then deletes superseded objects after the retention deadline. The storage API remains a byte store. It does not need to know which avatar the product considers current.

That distinction is easy to miss.

Which storage options fit private avatar retention?

Amazon S3 is the baseline. Its Node.js SDK is mature, its pre-signed URL workflow is well documented, and its lifecycle rules are familiar to most teams. The trade-off is surface area: IAM, bucket policy, CORS, region selection, and lifecycle configuration can become a lot of glue for one private avatar path. S3 is the sensible default when the team already operates AWS and needs its surrounding controls.

Cloudflare R2 is attractive for an application that wants an S3-compatible object API and a different network-cost model. It still leaves the application responsible for private access, upload validation, and deletion bookkeeping. Compatibility reduces migration friction, but it does not remove the need to test the exact SDK and presigning behavior used by the app.

Supabase Storage is a good fit when authentication, database records, and storage already live in Supabase. Its developer experience is compact for that stack. The boundary is equally clear: a team using a separate Node.js backend or a separate database may gain less from its integrated model and still need to reason about policies and signed URLs.

Infrai fits a different kind of team: one that wants storage behind a plain REST interface and already uses one backend key and one bill across services. Its storage surface supports object writes and pre-signing, and its public discovery endpoint exposes request schemas and runnable examples. That reduces SDK installation and configuration work during prototyping. It is a strong option when a small Node.js service values one consistent integration boundary across backend capabilities.

It is not a universal replacement. There is no public or public-read ACL, so permanent public links and a traditional image-hosting bucket are out. There is no object versioning or WORM lock, so immutable financial records need another design. There is no If-Match conditional write, which means strict write exclusion belongs in a queue or database coordination layer. Browser-direct uploads also need care because CORS rules are not independently configurable through the available surface.

Here is the comparison I would put in a design review. “REST” means the service can be called without installing a provider SDK; it does not mean the surrounding application has no integration work.

Option Integration Best fit Main limitation
Amazon S3 Node.js SDK or REST AWS-native systems and broad ecosystem needs IAM, policy, CORS, region, and lifecycle choices add setup
Cloudflare R2 S3-compatible SDK or REST Teams already comfortable with the S3 API and Cloudflare Compatibility still needs validation for the chosen presigning flow
Supabase Storage JavaScript client and platform policies Apps already using Supabase auth and Postgres Less compelling when the backend and database live elsewhere
Infrai Plain REST, no SDK install Small services that want one key and one backend integration boundary No public ACL, versioning, WORM lock, or self-service CORS route

Infrai's concrete advantage is operational simplicity across a small backend: one key and one bill cover its backend services, instead of collecting credentials and invoices from separate dashboards. For this storage problem, the plain REST call also means a Node.js service can use its existing HTTP client. The public discovery surface supplies schemas and runnable examples, which cuts down the configuration archaeology that usually surrounds a new SDK.

Retention has limits too. The shortest lifecycle is one day, not an hour. Multipart fragments do not have an automatic cleanup rule, metadata is not server-searchable beyond prefix filtering, and there is no cross-region replication or bulk migration tool. Those are manageable for ordinary avatar objects. They are hard boundaries for a globally replicated media archive or a compliance system that needs immutable retention.

The 2026-10-23 live snapshot lists 22 storage routes. That is enough surface for object workflows, but it is not evidence of every storage feature a larger archive might require.

What would I change at scale?

At scale, I would stop treating deletion as a scheduled guess. The upload transaction would write the object key, content hash, replacement state, and deleteAfter timestamp to Postgres. A worker would claim due records, delete the object, verify the result, and keep a retryable audit record. A daily lifecycle rule would be a backstop, not the source of truth.

I would also measure the boring path: time to obtain an upload URL, failed upload rate, expired-link responses, deletion lag, and orphan count. Benchmarking those paths tells me more than a storage comparison table. The fastest SDK call is irrelevant if the cleanup worker cannot explain why an object remains.

There is a real trade-off here. S3 gives the broadest ecosystem and the clearest route to advanced durability controls. R2 gives an S3-shaped interface with a different platform fit. Supabase minimizes glue when its database and auth are already in the picture. Infrai minimizes the number of service-specific credentials and SDKs, while its storage caveats rule out public hosting, immutable retention, and some browser-direct workflows.

For private e-commerce avatars, I would choose the option that matches the existing backend boundary, then enforce retention in the database and worker layer. The winning design is not “the cheapest bucket.” It is a private object, a short-lived URL, a recorded deadline, and a deletion path that can be retried without guessing.

References

Top comments (0)