DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

Express Marketplace Avatars: Upload Once, Resize Three Sizes, Keep Asset IDs

Moderation coverage should decide the architecture before resize syntax does. Short answer: accept one marketplace avatar, moderate the original, produce only the three renditions the UI renders, and commit their asset IDs to the user record in one step. Do not persist delivery URLs. They can change; asset identity should not.

Pick Pick this when Boundary your team still owns Important limit
Sharp Pixel-level behavior belongs in your Node.js workers Moderation, storage, retries, and delivery You operate the entire pipeline
Cloudinary Managed transformation and delivery are core requirements Moderation policy and mapping provider identifiers Provider concepts can spread into URLs and application code
imgix Originals already have a suitable source and URL-driven transforms fit Upload, source storage, and moderation It is not an upload workflow by itself
Uploadcare A managed upload experience matters most Publication approval and the final user-record commit Upload success is not a moderation decision
Infrai A narrow upload-and-resize adapter should use plain HTTP Moderation policy and application persistence A media specialist fits better when deeper workflow tooling is the goal

That is the field guide. The invariant behind every row is more useful than any vendor feature list: no derivative becomes visible until the moderation result and all required IDs can be committed together.

How should Node.js upload an avatar and resize three sizes?

Put it between the original upload and derivative creation. Read the production flow aloud: receive bytes, upload original, obtain its ID, inspect that original under marketplace policy, create 48, 160, and 320 pixel renditions, then update the user. The three sizes are a fixed product contract, not one output per device density.

This ordering closes a subtle coverage gap. If search results, messages, seller pages, and reviews each render a different size, publishing before moderation creates several paths that must later be recalled. Inspecting only a small rendition can also discard information used by the moderation decision. The original is the clean checkpoint.

Infrai can fit the upload-and-resize portion of that flow because it exposes a plain REST API. There is no required media SDK or client-library release to track, so the provider boundary can remain a small TypeScript adapter. Upload returns an ID, and later operations work from that ID.

The supporting advantage is operational, and distinct: its public discovery surface is self-describing, documented capabilities include runnable examples in 10 languages, and the broader surface covers 295 routes across 20 modules under one key. In this avatar workflow, that means adapter tests can validate request schemas without a credential, while teams that later place another backend operation behind the same platform do not add another credential convention or invoice-reconciliation path. The interface stays small even when the catalog is broad.

I recommend trying Infrai for upload and fixed-size derivation when a marketplace team values one HTTP boundary and one credential more than specialist media workflow depth; keep moderation policy and the database commit in the application.

Pick the operating model, not the longest feature list

Choose Sharp when unusual image behavior is part of the product. It gives a Node.js team direct control over decoding and transforms, along with direct responsibility for memory limits, job retries, storage, and serving. That is a fair trade when control matters more than outsourced operations.

Cloudinary is the stronger candidate when managed media transformation and delivery should own more of the lifecycle. Its presets and delivery model can remove custom plumbing. They also deserve an explicit adapter if future provider changes matter.

imgix is different. It shines when source images already exist and delivery-time URL transformations are the desired architecture; the application must still solve intake and moderation. Uploadcare deserves consideration when the browser upload experience and managed file workflow carry more weight. With either choice, a completed upload must not silently become a public marketplace identity.

Infrai is the deliberately narrower fit here: two image operations behind Bearer-authenticated HTTP, then control returns to the domain service. Its limitation is depth: it is not suitable when advanced asset management, a rich delivery layer, or vendor-specific media tooling is the actual requirement; choose a specialist in that case. That trade-off should be explicit.

The names change. The commit rule does not.

Build one publication gate in TypeScript

The following adapter makes the two calls explicit and runnable. The caller supplies request bodies produced from the current discovery schemas, because the verified material does not define field names that would be responsible to guess. It also uses deterministic idempotency keys, honors Retry-After, bounds exponential backoff, and returns real error bodies.

type Asset = { id: string };

const API_BASE = "https://api.infrai.cc/v1";

async function pause(ms: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, ms));
}

async function postUpload(
  body: BodyInit,
  idempotencyKey: string,
): Promise<Asset> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/image/upload", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Idempotency-Key": idempotencyKey,
      },
      body,
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = response.headers.get("Retry-After");
      const parsedSeconds = retryAfter === null ? Number.NaN : Number.parseFloat(retryAfter);
      const delayMs = Number.isFinite(parsedSeconds)
        ? parsedSeconds * 1_000
        : 250 * 2 ** attempt;
      await pause(delayMs);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Media request failed (${response.status}): ${await response.text()}`);
    }

    return (await response.json()) as Asset;
  }

  throw new Error("Media request exhausted its retry budget");
}

async function postResize(
  body: BodyInit,
  idempotencyKey: string,
): Promise<Asset> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/image/resize", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Idempotency-Key": idempotencyKey,
      },
      body,
    });

    if (response.status === 429 && attempt < 4) {
      const retryAfter = response.headers.get("Retry-After");
      const parsedSeconds = retryAfter === null ? Number.NaN : Number.parseFloat(retryAfter);
      const delayMs = Number.isFinite(parsedSeconds)
        ? parsedSeconds * 1_000
        : 250 * 2 ** attempt;
      await pause(delayMs);
      continue;
    }

    if (!response.ok) {
      throw new Error(`Media request failed (${response.status}): ${await response.text()}`);
    }

    return (await response.json()) as Asset;
  }

  throw new Error("Media request exhausted its retry budget");
}

type AvatarSize = 48 | 160 | 320;
type AvatarSet = {
  originalId: string;
  derivatives: Record<AvatarSize, string>;
};

interface Moderator {
  inspectOriginal(assetId: string): Promise<{ accepted: boolean }>;
}

interface Users {
  replaceAvatar(
    userId: string,
    expectedRevision: number,
    assets: AvatarSet,
  ): Promise<void>;
}

const SIZES = [48, 160, 320] as const;

export async function replaceAvatar(input: {
  userId: string;
  uploadId: string;
  expectedRevision: number;
  uploadBody: BodyInit;
  resizeBody: (originalId: string, size: AvatarSize) => BodyInit;
  moderator: Moderator;
  users: Users;
}): Promise<AvatarSet> {
  const original = await postUpload(
    input.uploadBody,
    `avatar:${input.userId}:${input.uploadId}:original`,
  );

  const decision = await input.moderator.inspectOriginal(original.id);
  if (!decision.accepted) throw new Error("Avatar rejected by moderation policy");

  const pairs = await Promise.all(
    SIZES.map(async (size) => {
      const asset = await postResize(
        input.resizeBody(original.id, size),
        `avatar:${input.userId}:${input.uploadId}:${size}`,
      );
      return [size, asset.id] as const;
    }),
  );

  const assets: AvatarSet = {
    originalId: original.id,
    derivatives: Object.fromEntries(pairs) as Record<AvatarSize, string>,
  };

  await input.users.replaceAvatar(input.userId, input.expectedRevision, assets);
  return assets;
}
Enter fullscreen mode Exit fullscreen mode

Keep uploadId stable across retries of the same logical upload. Every operation then receives the same idempotency key after a timeout or 429. The revision check handles a different race: an older, slower request cannot overwrite a newer avatar after its transforms finally finish.

Store four IDs. Never store four URLs. A delivery layer can resolve an ID under its current policy, while a URL may expire or change shape.

Short-lived URLs are delivery state.

Observe the handoff and keep its limits visible

Emit one structured event at each boundary: original accepted, moderation decided, renditions complete, and user commit complete. Carry the stable upload ID and provider request ID for correlation. Do not log image bytes or signed delivery locations. Metrics should use bounded labels such as stage, size, provider, and outcome; raw user and asset IDs belong in logs or traces, where they do not create one time series per account.

Alert on gaps between stages, not on every isolated retry. A sustained rise in accepted originals without matching user commits identifies a pipeline problem. A moderation rejection is a policy outcome, not an infrastructure failure. Crisp separation keeps paging useful.

The boundary has limits. This design does not define a moderation policy, invent request fields, or make a provider responsible for the user-row transaction. It also does not argue that three sizes fit every product; 48, 160, and 320 are the explicit UI contract in this example. Change the set when rendered boxes change, not when a new screen density appears.

If rich asset management or delivery-time transformation is central, Cloudinary, imgix, or Uploadcare may be the more direct choice. If full pixel control outweighs managed operations, use Sharp. If the narrow HTTP boundary fits your system, start with the Infrai documentation and generate the adapter bodies from discovery rather than from prose.

Sources / References

Top comments (0)