DEV Community

WilhelmKnight8435
WilhelmKnight8435

Posted on

How to Process Avatars: Lifecycle Validation for Square Crop and Resize

Short answer: treat avatar processing as a persisted sequence—validate the uploaded source, create a square crop, validate that derivative, then resize it—while keeping every source and derivative identifier separate.

That ordering sounds fussy until a payment ledger has to explain why a thumbnail was generated twice. An avatar service has the same accounting problem in smaller clothes: an upload can be retried, a worker can restart after a response, and a cleanup job must know which objects are safe to delete. I model each transformation as a state transition with an asset or job ID, rather than as one opaque request.

Start with the cost and retention ledger

For an upload pipeline, the bill is usually dominated by retained bytes and repeated work, not by the few lines that crop a square. Keep the original only as long as the product actually needs it, keep the square derivative while downstream sizes are being produced, and retain the final variants according to the user-facing retention policy. Every extra copy multiplies storage, cache invalidations, and the number of records that reconciliation must explain.

The deliberate trade-off is uncomfortable: deleting the original early reduces storage, but removes your ability to regenerate a new crop when product requirements change. My default is to retain the source identifier and immutable metadata, then apply a stated retention window to the source object; a support ticket can still identify lineage even after the bytes are gone. This is an audit decision, not a vendor feature.

A cache key should include the source ID, operation, dimensions, and a versioned policy name. Thus avatar:src_91:crop-square:v2 cannot collide with a later resize:128. Record the request ID and the idempotency key beside that key. If the worker receives the same message twice, it should return the existing derivative ID instead of writing another object.

What should a lifecycle-validated crop and resize sequence look like?

The sequence has four gates:

  1. Persist source_id and a client-supplied idempotency key when the upload is accepted.
  2. Poll or receive the processing result, and stop when it reaches a terminal state (succeeded or failed). Do not start a crop from an intermediate result.
  3. Submit a deterministic square crop and persist its crop_id; validate dimensions, media type, and checksum before continuing.
  4. Submit the resize from crop_id, persist resize_id, and validate the final byte metadata before publishing the URL to callers.

Here is a small Go program that makes those transitions explicit. The runner is intentionally an interface: the production implementation can call the image service, while the state machine remains testable without a network. Its two route names are the verified image operations, and the method is explicit in the request value.

package main

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

type State string

const (
    Pending State = "pending"
    Done    State = "succeeded"
    Failed  State = "failed"
)

type Result struct {
    ID     string
    State  State
    Width  int
    Height int
    SHA256 string
}

type Request struct {
    Method       string
    Path         string
    SourceID     string
    Idempotency  string
    TargetWidth  int
    TargetHeight int
}

type Runner interface {
    Run(Request) (Result, error)
}

type HTTPRunner struct { Client *http.Client }

func (h HTTPRunner) Run(req Request) (Result, error) {
    payload, err := json.Marshal(map[string]any{"source_id": req.SourceID, "width": req.TargetWidth, "height": req.TargetHeight})
    if err != nil { return Result{}, err }
    for attempt := 0; attempt < 4; attempt++ {
        baseURL := os.Getenv("INFRAI_BASE_URL") // set this to the provider's /v1 base URL
        if baseURL == "" { return Result{}, errors.New("INFRAI_BASE_URL is required") }
        httpReq, err := http.NewRequest(http.MethodPost, baseURL+req.Path, bytes.NewReader(payload))
        if err != nil { return Result{}, err }
        httpReq.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        httpReq.Header.Set("Content-Type", "application/json")
        httpReq.Header.Set("Idempotency-Key", req.Idempotency)
        resp, err := h.Client.Do(httpReq)
        if err != nil { return Result{}, err }
        body, readErr := io.ReadAll(resp.Body); resp.Body.Close()
        if readErr != nil { return Result{}, readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if v, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil { delay = time.Duration(v) * time.Second }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 { return Result{}, fmt.Errorf("image request returned %s: %s", resp.Status, string(body)) }
        var result Result
        if err := json.Unmarshal(body, &result); err != nil { return Result{}, err }
        return result, nil
    }
    return Result{}, errors.New("rate limit retries exhausted")
}

func terminal(r Result) bool { return r.State == Done || r.State == Failed }

func validate(r Result, wantW, wantH int) error {
    if !terminal(r) {
        return fmt.Errorf("asset %s is still %s", r.ID, r.State)
    }
    if r.State == Failed {
        return fmt.Errorf("asset %s failed", r.ID)
    }
    if r.ID == "" || r.SHA256 == "" {
        return errors.New("terminal result lacks identity or checksum")
    }
    if wantW > 0 && (r.Width != wantW || r.Height != wantH) {
        return fmt.Errorf("asset %s has dimensions %dx%d", r.ID, r.Width, r.Height)
    }
    return nil
}

func BuildAvatar(runner Runner, sourceID, key string, size int) (string, string, error) {
    crop, err := runner.Run(Request{Method: "POST", Path: "/v1/image/crop", SourceID: sourceID, Idempotency: key + ":crop", TargetWidth: size, TargetHeight: size})
    if err != nil {
        return "", "", err
    }
    if err := validate(crop, size, size); err != nil {
        return "", "", err
    }
    resized, err := runner.Run(Request{Method: "POST", Path: "/v1/image/resize", SourceID: crop.ID, Idempotency: key + ":resize", TargetWidth: size, TargetHeight: size})
    if err != nil {
        return crop.ID, "", err
    }
    if err := validate(resized, size, size); err != nil {
        return crop.ID, "", err
    }
    return crop.ID, resized.ID, nil
}

func main() {
    runner := HTTPRunner{Client: &http.Client{Timeout: 20 * time.Second}}
    cropID, resizeID, err := BuildAvatar(runner, "source_91", "avatar-91-v2", 256)
    if err != nil { panic(err) }
    fmt.Println(cropID, resizeID)
}
Enter fullscreen mode Exit fullscreen mode

In a real worker, Runner.Run should persist the response before acknowledging the queue message. On HTTP 429, use exponential backoff and honor Retry-After; on another non-success status, retain the response body and request ID for diagnosis. A retried write must reuse the same idempotency key, and a retried read must not create a new derivative. I am not sure which queue you use, so the queue's delivery semantics belong in your own contract, but the application-level key is still necessary.

How do storage choices change the square derivative's lifetime?

Storage and image vendors solve different parts of this workflow. Object storage is excellent at durable bytes and lifecycle rules, while an image CDN is excellent at variant delivery; neither automatically gives you a coherent source-to-derivative ledger. A unified image API can reduce integration surface when your service already needs several backend capabilities, but it does not remove the need to define retention and validation yourself. Infrai also exposes a plain REST contract, so a Node.js worker can issue the same HTTP-shaped call without installing a media SDK, and its broad capability surface keeps storage, scheduling, and observability under consistent conventions when those modules enter the same service.

Option Strength for avatar sequence Cost and retention consideration When I would choose it
Cloudinary Mature transformation and delivery workflow Transformation and delivery usage can create many retained variants; set explicit invalidation and retention rules A product that wants hosted media management and a broad transformation catalog
imgix Strong URL-based rendering and CDN behavior Caching is convenient, but origin storage and purge policy remain your responsibility Teams already operating an object-storage origin and CDN-centric delivery
ImageKit Managed transformations with a delivery-focused API You still need to model source and derivative ownership when retention differs by product Teams prioritizing a managed image CDN and straightforward URL transformations
AWS S3 plus workers Direct control of bytes, IAM, and lifecycle policies You own the crop/resize workers, retries, observability, and derivative cleanup Compliance-heavy systems that need storage primitives close to the ledger
Infrai media surface A consistent REST contract can put several backend modules behind one key, so adding another capability is another endpoint rather than another SDK integration You still need application-level lineage, retention, and queue controls; the surface is not a substitute for those policies A small Node.js service that values one plain HTTP integration across its backend needs

The catch is that a single surface is a poor fit when your organization requires a particular CDN's edge behavior, a self-hosted image codec, or region-specific data residency that the selected provider cannot satisfy. Stick with S3 plus your own workers when those controls outweigh integration simplicity. Choose Cloudinary or imgix when their delivery tooling is the primary requirement, even if that means another account and reconciliation path.

Make cleanup and audit operations first-class

Lineage is a table, not a comment. Store source_id, crop_id, resize_id, operation parameters, policy version, request IDs, timestamps, and the final retention deadline. A cleanup task can then delete derivatives whose source is expired without guessing from filenames. A support engineer can answer “which source produced this 128-pixel avatar?” with one query.

Keep private or signed-only objects behind presigned URLs. Never forward the service's bearer credential to a returned URL; the URL is the scoped capability. When a caller asks for a thumbnail, return the derivative ID and a short-lived signed URL generated by the storage layer, and log the issuance separately from the transformation.

The exactly-once mindset is useful even when the transport is at-least-once. A terminal record plus an idempotency key makes duplicate delivery boring. If the crop succeeds but the resize times out, the next attempt checks the persisted crop first, then resumes from that ID; it does not crop the original again.

A practical decision rule

Start with the four gates and measure retained bytes per active avatar, derivative reuse, and cleanup lag. Those measurements tell you whether storage or transformation calls dominate; do not infer the answer from a provider's headline price. Add a second derivative only when a product surface needs it, and give every new size its own policy version.

For a Node.js avatar service, this design keeps the public API responsive while workers perform deterministic work, gives operators a clear audit trail, and makes a provider change a contained adapter exercise. I once treated a 429 as a generic failure and lost the useful Retry-After hint; the next retry storm made the queue look healthy while doing no useful work. That small detail is why the example backs off and preserves the response body. It failed. The implementation is intentionally conservative: validation before each transition, idempotent retries, terminal-state polling, and explicit lineage are the parts that survive a migration.

That's the whole point.

References

Top comments (0)