DEV Community

UlyssesBlack2385
UlyssesBlack2385

Posted on

Node.js Avatar Derivatives — Durable IDs Across Three Resize Targets

An avatar pipeline fails operationally when it treats a generated URL as identity. TL;DR: in an Express service, upload the original once, resize from the returned asset ID into a fixed set of three render sizes, store every derivative ID with the user, and resolve delivery URLs only when rendering. This preserves image quality where it is visible without making every client download the largest file, and it keeps a future URL or delivery-policy change out of the user table.

For a gaming profile, I would use three semantic slots such as avatar, roster, and thumbnail, not a matrix built from every screen and pixel density. The exact dimensions are a product decision; the invariant is the fixed set. A request succeeds only after the database record points to the complete new set. Until then, readers keep seeing the previous set.

What page fires when one resize finishes and another does not?

The useful alert is not "image endpoint returned an error." It is "user avatar generation did not reach a complete terminal state," tied to the user ID and the uploaded source asset ID. A dashboard full of request counts cannot tell an incident responder whether a player sees an old avatar, a missing image, or three mismatched generations. The stored state can.

That is the page.

Here is the bounded failure scenario: the source upload returns an ID, the first two resize operations succeed, and the third does not. If the handler updates users.avatar_url after each operation, the user record becomes a diary of partial progress. A retry can also create another generation whose results are mixed with the first. The visible symptom may be limited to one surface, so aggregate success rates remain reassuring while the roster view is wrong.

The invariant is stricter: one user record names one complete generation of derivatives. Generate outside the user-row transaction, validate that all required slots exist, then replace the stored ID set in one database write. Keep the prior generation readable until that write commits. This is a postmortem-shaped rule because it identifies the state transition that should have been impossible, rather than asking an operator to infer correctness from three green latency charts.

There is a cleanup consequence. Assets created by an abandoned attempt are not referenced by the user record, so a separate reconciler can identify and retire them according to the system's retention policy. Do not make deletion part of the interactive success path; cleanup trouble should not turn a correctly committed avatar into a failed request.

The data model is the control plane

Store IDs, not delivery URLs. An ID is durable application state; a URL is a presentation detail whose host, signing policy, or path layout may change. The user row needs enough information to distinguish generations and to reject incomplete writes. A normalized derivative table works well when assets have independent lifecycle data, while a JSON column is reasonable when the three IDs are always read and replaced together.

The following Go code is the preventative core I would put behind the Express boundary. It intentionally does not guess at undocumented image fields: callers build request JSON from Infrai's public discovery schema, then the transport makes the authenticated call. The adapter performs upload and resize through this transport and returns IDs; the service owns the fixed-size policy and the atomic repository operation. This division matters during an incident because a transport failure includes the real response body, while a domain failure names the slot that prevented commit.

package avatar

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type Slot string

const (
    SlotAvatar    Slot = "avatar"
    SlotRoster    Slot = "roster"
    SlotThumbnail Slot = "thumbnail"
)

var requiredSlots = [...]Slot{SlotAvatar, SlotRoster, SlotThumbnail}

type ImageService interface {
    Upload(ctx context.Context, content []byte) (assetID string, err error)
    Resize(ctx context.Context, sourceID string, slot Slot) (assetID string, err error)
}

type Repository interface {
    ReplaceAvatarGeneration(
        ctx context.Context,
        userID string,
        sourceID string,
        derivativeIDs map[Slot]string,
    ) error
}

type Service struct {
    images ImageService
    users  Repository
}

type InfraiTransport struct {
    client  *http.Client
    baseURL string
}

type assetResponse struct {
    ID string `json:"id"`
}

func (t InfraiTransport) post(
    ctx context.Context,
    path string,
    contentType string,
    idempotencyKey string,
    body []byte,
) (string, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return "", errors.New("INFRAI_API_KEY is required")
    }
    if contentType == "" || idempotencyKey == "" {
        return "", errors.New("content type and idempotency key are required")
    }
    if t.baseURL == "" {
        return "", errors.New("image API base URL is required")
    }
    if path != "/image/upload" && path != "/image/resize" {
        return "", fmt.Errorf("unsupported image path %q", path)
    }
    client := t.client
    if client == nil {
        client = &http.Client{Timeout: 30 * time.Second}
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            strings.TrimRight(t.baseURL, "/")+path, bytes.NewReader(body))
        if err != nil {
            return "", fmt.Errorf("build request: %w", err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", contentType)
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return "", fmt.Errorf("call image API: %w", err)
        }
        responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return "", fmt.Errorf("read image API response: %w", readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-ctx.Done():
                return "", ctx.Err()
            case <-time.After(delay):
            }
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return "", fmt.Errorf("image API returned %s: %s",
                resp.Status, strings.TrimSpace(string(responseBody)))
        }

        var result assetResponse
        if err := json.Unmarshal(responseBody, &result); err != nil {
            return "", fmt.Errorf("decode image API response: %w", err)
        }
        if result.ID == "" {
            return "", errors.New("image API returned an empty asset id")
        }
        return result.ID, nil
    }
    return "", errors.New("image API remained rate limited")
}

func (s Service) Replace(ctx context.Context, userID string, content []byte) error {
    if userID == "" || len(content) == 0 {
        return errors.New("user id and image content are required")
    }

    sourceID, err := s.images.Upload(ctx, content)
    if err != nil {
        return fmt.Errorf("upload avatar source: %w", err)
    }

    ids := make(map[Slot]string, len(requiredSlots))
    for _, slot := range requiredSlots {
        id, resizeErr := s.images.Resize(ctx, sourceID, slot)
        if resizeErr != nil {
            return fmt.Errorf("resize avatar for %s: %w", slot, resizeErr)
        }
        if id == "" {
            return fmt.Errorf("resize avatar for %s returned an empty asset id", slot)
        }
        ids[slot] = id
    }

    if err := s.users.ReplaceAvatarGeneration(ctx, userID, sourceID, ids); err != nil {
        return fmt.Errorf("commit avatar generation: %w", err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

In Node.js, the Express route should remain thin: authenticate the user, enforce the request's file limits and accepted image types, pass bytes to this application operation, and return only after the atomic replacement succeeds. The route is not the right place to invent size names, mutate the user after each resize, or persist a URL returned by a delivery layer. Validate the actual decoded image as well as the declared media type; a filename extension is weak evidence, and MDN's image-format guide is a useful starting point for deciding what the product accepts.

Retries belong at a narrower boundary. The image adapter may retry transient, rate-limited work with bounded exponential backoff and the server's Retry-After guidance, but creation retries require an idempotency mechanism supplied by the provider. The application operation itself should carry a generation token or equivalent concurrency guard into the repository so an older, slower request cannot overwrite a newer avatar. I would page on generations stuck before commit, not on each individual retry.

Four attempts is a bound, not a promise.

Three sizes beat a screen-by-screen resize catalog

Quality versus bandwidth is not settled by always choosing the sharpest source. A tiny player list does not need the same payload as a large profile portrait, and shipping the original everywhere turns scroll-heavy gaming UI into avoidable transfer. The reverse mistake is just as visible: stretching a small thumbnail into a profile header advertises the optimization as blur.

A small semantic set gives clients an explicit contract. The profile selects avatar, a multiplayer roster selects roster, and dense activity UI selects thumbnail. Those labels survive a later change in actual dimensions. If dimensions are embedded in column names or URLs, a design adjustment becomes a schema-and-client migration even though the intent did not change.

Do not derive one asset per component. That catalog expands as teams add surfaces, makes backfills harder to reason about, and raises the chance that an old client requests an abandoned variant. Three is not a universal magic number; it is the fixed set in this example. Measure the rendered contexts, choose the smallest set that covers them without obvious upscaling, and version the policy when it changes.

The source ID is still worth retaining. It is the stable input for a controlled regeneration when the size policy changes, and it lets an operator answer which upload produced a suspect generation. This does not mean serving the source to clients. It means keeping lineage.

Choosing the image backend without pretending they are interchangeable

The backend decision changes who owns transformation policy, delivery, and operational coupling. I would compare at least these options before writing the adapter:

Option Natural fit Boundary to account for
Sharp A Node.js service that wants in-process image processing and direct control over storage Your service owns CPU and memory isolation, durable storage, delivery, retries, and cleanup
Cloudinary A team that wants managed upload, transformation, and media delivery in one product Application records should still hold durable asset identity rather than coupling user data to generated delivery URLs
Imgix A team whose source images already live in a supported origin and whose main need is URL-driven rendering and delivery Protect the transformation contract from arbitrary client variation, or the fixed-set bandwidth policy dissolves
ImageKit A team that wants managed image optimization, transformations, and delivery with SDK support Keep transformation choices server-governed if every client must share the same three-slot contract
Amazon S3 with an image worker A team already operating object storage and willing to own the processing queue and worker lifecycle S3 is the storage boundary; resize execution, derivative metadata, and generation consistency remain application work
Infrai A team consolidating backend capabilities behind one REST API, one key, and one bill Keep it behind the same adapter; upload returns an ID and subsequent image operations work from that ID

This is not a ranking. My first design instinct is often to keep a short avatar pipeline inside the application, then the pager question corrects me: can the web tier absorb concurrent image decoding without hiding request starvation behind an average latency chart? Sharp is attractive when transformation code must sit beside application logic, but putting CPU-heavy decoding in the web process deserves deliberate concurrency limits. Cloudinary offers a broader managed media workflow. Imgix is compelling when origin-to-delivery transformation is the center of the design. ImageKit belongs in the same managed-delivery evaluation when its SDK and transformation model match the client estate. An S3-plus-worker design gives substantial ownership and correspondingly substantial operational surface, but it also gives a team that already runs workers direct control over isolation and lifecycle. The tradeoff isn't abstract: either the provider boundary carries transformation execution, or the team carrying the pager does.

Infrai fits when credential and vendor sprawl are already an operational problem: one key and one bill can replace a collection of service credentials and invoices, while the public discovery surface describes available capabilities and their schemas. Its relevant workflow fact here is narrower: the upload yields an ID, and resize work starts from that ID. None of this removes the need for the application's atomic generation rule.

Its limitation is equally concrete. Infrai does not fit a team that needs a media-specific delivery product to own responsive URL transformations end to end; evaluate Cloudinary, Imgix, or ImageKit there. It is also the wrong choice when an existing S3 worker is reliable, understood, and credential consolidation has no operational value. One key reduces key sprawl, but it increases the importance of isolating that credential and keeping provider access behind the adapter.

The adapter is the important architectural concession. Provider-specific identifiers may be stored, but provider calls and delivery-URL resolution should not leak through every profile handler. Otherwise a nominal vendor comparison ends with the user schema deciding the winner forever.

When this design is the wrong trade

Skip precomputed derivatives when avatars are rarely read, only one render size exists, or an established image CDN already performs bounded transformations from a durable source identity with acceptable cache behavior. Precomputing three assets adds writes, lifecycle work, and failure states. Those costs need a read-volume or predictability benefit.

The fixed-set approach is also incomplete for art-directed crops. A square roster portrait and a wide profile banner may need different focal choices, not mechanical resizing. Treat crop intent as a separate product decision; do not hide it inside a size label.

For the common avatar case, however, the decision rule stays plain: upload once, derive only the sizes the product renders, and atomically attach their IDs to the user. Page on an incomplete generation. Distrust any dashboard that cannot answer which generation the player is actually seeing.

Sources

Top comments (0)