DEV Community

SyltharWave2946
SyltharWave2946

Posted on

Avatar Image Storage: A Decision Record for Compatible Objects and Retention

Short answer: For a startup app, private object storage with an S3-compatible interface is a practical baseline for avatar image storage when it is paired with versioned keys, database-owned state, asynchronous thumbnails, and lifecycle rules limited to disposable objects.

For a startup app, the cheapest simple design for avatar images is usually a private object-storage bucket with an S3-compatible API, immutable object keys, and a database record that owns each avatar. Generate thumbnails asynchronously, keep them as explicit derivatives, and make cleanup an application workflow backed by lifecycle rules rather than treating a bucket as a garbage collector.

The important decision is not which bucket has the lowest advertised rate. It is which system can prove that an old avatar, its derivatives, and its database reference move through the same retention policy without deleting a replacement or retaining unbounded thumbnail drift.

The architecture decision and its invariants

I would record these invariants before choosing a provider:

  • The database is the authority for the current avatar version.
  • Object keys contain an opaque avatar version, so an upload never overwrites the object currently being served.
  • The original and every thumbnail variant carry the same version identifier.
  • Reads are private by default; the application authorizes access and either streams the object or issues a short-lived URL.
  • Deletion is idempotent and observable. A second delete is not a new failure mode.

That gives the upload path a narrow responsibility: write the original, create a pending database version, produce derivatives, then publish the version with one database transition. A client must not be allowed to infer a thumbnail key from a mutable username or profile ID alone. That is how a late image-processing job can replace a newer avatar while appearing to succeed.

The boundary is deliberate. Object storage gives you bytes and object metadata; it doesn't give you a transactional commit across a database row, an image processor, and several objects. Treating those systems as one transaction creates a failure mode that is difficult to repair.

Option Useful property Failure boundary Choose it when
One original, resize on read Few stored objects Slow first request and repeated CPU work Traffic is tiny and image dimensions are flexible
Original plus fixed derivatives Predictable reads and bounded formats Orphaned variants need cleanup Avatar sizes are known and latency matters
CDN transformation on demand Processing moves out of the app Cache invalidation and provider coupling The team already operates that delivery path
Database or filesystem blobs One local ownership model Scaling, backup, and serving become coupled Deployment is deliberately single-node

There is no universal winner. It's a small distinction — and the record should preserve it, because storage decisions tend to outlive the person who made them.

How can a startup app keep avatar image storage compatible?

Use a key layout that makes ownership and retention visible without putting mutable business data in the key. For example:

from dataclasses import dataclass


@dataclass(frozen=True)
class AvatarObjects:
    version: str
    original: str
    thumbnails: tuple[str, ...]


def avatar_objects(user_id: str, version: str, sizes: tuple[int, ...]) -> AvatarObjects:
    prefix = f"avatars/{user_id}/{version}"
    return AvatarObjects(
        version=version,
        original=f"{prefix}/original",
        thumbnails=tuple(f"{prefix}/thumb-{size}" for size in sizes),
    )
Enter fullscreen mode Exit fullscreen mode

The exact SDK call varies, which is why the design should depend on the S3 object operations rather than a vendor-specific image feature. The critical path still has a recognizable order:

def publish_avatar(storage, db, user_id: str, content: bytes, version: str) -> None:
    objects = avatar_objects(user_id, version, (64, 256))
    db.create_pending_avatar(user_id, version, objects.original)
    storage.put_object(objects.original, content, content_type="image/jpeg")

    for key, size in zip(objects.thumbnails, (64, 256)):
        storage.put_object(key, resize_image(content, size), content_type="image/jpeg")

    db.publish_avatar(user_id, version, objects.original, objects.thumbnails)
Enter fullscreen mode Exit fullscreen mode

In production, the processing step belongs behind a queue or retryable job, and the database transition needs a state such as pending, published, or deleting. The snippet shows the ownership boundary, not a claim that a function call makes four remote writes atomic. If the original succeeds and thumbnail generation stops halfway, the published version must remain hidden and a repair job must be able to resume from object existence checks.

Lifecycle rules are useful for bounded temporary data: incomplete uploads, abandoned pending versions, or objects under a clearly temporary prefix. They are a poor substitute for knowing whether a published version is still referenced. A lifecycle expiration based only on object age can delete a valid avatar when a user has not logged in recently. Keep the application-owned deletion decision separate, and use lifecycle cleanup as a backstop for states that are explicitly safe to expire.

The failure modes deserve names: orphaned original, orphaned derivative, stale publish, delete-before-publish, and thumbnail drift. Each should have a metric and a repair path. I would run a nightly reconciliation that compares published database versions with object metadata, but the reconciler should report first and delete only after a retention delay; a race between a retrying upload and an eager cleanup task is still a race.

What does “cheapest” mean after the first upload?

Storage price is only one term in the cost model. Requests, egress, image-processing CPU, CDN cache misses, reconciliation jobs, backups, and engineering time all matter. A design with one original object may spend more on repeated resizing; three fixed derivatives may spend more bytes but make serving predictable. Your mileage will vary because traffic shape and egress geography dominate small startup workloads.

The practical comparison is a worksheet, not a leaderboard:

  1. Estimate originals and derivatives per active user.
  2. Set a maximum upload size and reject oversized or decompression-bomb inputs before processing.
  3. Measure reads by variant, cache hit rate, and egress rather than counting only stored gigabytes.
  4. Put a retention owner on every prefix: published, pending, failed, and deleted.
  5. Price the operational surface: alerts, backups, restore tests, and the code needed to migrate away.

A provider offering S3 compatibility can reduce adapter work, but “compatible” is not a complete contract. Verify the operations you need, metadata behavior, conditional writes, pagination, multipart upload semantics, deletion behavior, and presigned URL support against the provider's documentation and a small conformance test. Do not assume that an S3-shaped endpoint has identical consistency, limits, or billing semantics.

The rejected option: one mutable key per user

The tempting implementation is avatars/{user_id}.jpg, overwritten on every upload, with thumbnails named beside it. It is easy to explain and hard to reason about. A cache can serve the previous bytes after the database says the new avatar is current; a failed thumbnail job can leave a mixed set of generations; and cleanup cannot distinguish an old derivative from the current one without extra metadata.

It is still valid for a deliberately tiny internal tool where cache invalidation is irrelevant, the object is not versioned, and losing a stale thumbnail has no user impact. That is a narrow use case. For a public app, versioned keys cost little and give retries, audits, and garbage collection something concrete to inspect.

The other rejected shortcut is “expire everything after N days.” It suits temporary uploads, not user-owned profile data. Stick with lifecycle-only deletion when the data is truly disposable and no current-record relationship exists; otherwise, let the database establish eligibility and let a delayed worker perform deletion.

References

Top comments (0)