Short answer: put the image bytes in object storage, keep an object key and validation metadata in the database, and treat local disk as disposable. That split keeps avatar reads away from transactional connections, gives you an independent retention policy, and makes a second application instance uneventful.
I start design reviews with the failure budget, not the storage price. An avatar is useful, but it is not the identity system. If the image path is slow, the product should still authenticate users and render initials while the image request spends its error budget.
That's the whole test.
The failure pattern hiding behind a simple upload
The first design usually accepts the multipart body in the web process, writes it to a local path, and stores that path beside the user row. It is pleasantly short. It also makes deployment, autoscaling, and recovery part of the upload contract: a new instance has an empty filesystem, and a rebuilt instance has no reason to know which old paths mattered.
Database blobs remove that particular problem, but they create a different coupling. Avatar bytes now compete with profile updates for buffer cache, connection time, and backup throughput. A directory page that renders many users can turn a cosmetic read into a burst of database work. Nothing has to be intrinsically slow for the shared capacity budget to become the incident. In a review I write down the expected response explicitly: a storage timeout is a 503 for the image fetch, followed by initials, while the profile endpoint remains available; that small sentence forces the team to separate SLOs, retry budgets, and user-visible behavior before anyone argues about which database type is fashionable.
Object storage changes the boundary. The application authorizes a bounded write, the client sends bytes directly to the storage service, and the application records the result only after checking it. Reads can be redirected to a signed URL, so the web tier does not become an image proxy by accident.
That is the invariant I use: immutable presentation bytes should have an independent durability and capacity budget from mutable business rows.
What should a small app's avatar upload backend store: blob, disk, or object?
Use the same questions for all three choices: what happens when a node disappears, how does a restore meet the SLO, who owns lifecycle cleanup, and where may the data reside? Cost per gigabyte comes later; for small avatars, operational coupling is usually the larger number.
| Choice | Failure mode to model | Recovery shape | A reasonable boundary |
|---|---|---|---|
| Local disk | Deploy or node replacement removes files unless another backup is authoritative | Restore the host and its separate file backup together | One instance, one region, no near-term scale-out, and an accepted loss policy |
| Database blob | Image fan-out consumes the same pool and backup window as writes | Restore a larger database before the avatar path is complete | A genuinely tiny corpus where row deletion and bytes must be one transaction |
| Object storage | Permission, key, or regional routing mistakes are the main application risks | Restore the metadata row and reissue a read URL; object retention is separate | Multiple instances, independent scaling, or a recovery target that outlives one host |
The boundary is not a moral rule. A single-node prototype can choose disk and document that it is a cache, while a regulated system may keep a small attachment inside its database for audit semantics. The choice should be explicit in the runbook, including the point at which it must be revisited.
A bounded upload path in Go
The handler below illustrates the control plane. It never trusts a client filename, limits media types and size, and gives the client a short-lived permission for one generated key. The storage adapter can be backed by a managed or self-hosted implementation without changing the database contract.
package avatar import ( "context" "crypto/rand" "encoding/hex" "fmt" "net/http" "time" ) type Store interface { PresignPut(context.Context, string, string, int64, time.Duration) (string, error) Head(context.Context, string) (ObjectInfo, error) PresignGet(context.Context, string, string, time.Duration) (string, error) } type ObjectInfo struct { Size int64 ContentType string ETag string } var allowedTypes = map[string]string{ "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", } const maxAvatarBytes int64 = 2 << 20 func (s *Service) StartUpload(w http.ResponseWriter, r *http.Request) { userID, err := s.authenticate(r) if err != nil { http.Error(w, "unauthenticated", http.StatusUnauthorized) return } contentType := r.URL.Query().Get("content_type") ext, ok := allowedTypes[contentType] if !ok { http.Error(w, "unsupported image type", http.StatusUnsupportedMediaType) return } nonce := make([]byte, 16) if _, err := rand.Read(nonce); err != nil { http.Error(w, "unable to create upload", http.StatusInternalServerError) return } key := fmt.Sprintf("avatars/%s/%s.%s", userID, hex.EncodeToString(nonce), ext) url, err := s.store.PresignPut(r.Context(), key, contentType, maxAvatarBytes, 5*time.Minute) if err != nil { http.Error(w, "unable to create upload", http.StatusBadGateway) return } writeJSON(w, http.StatusOK, map[string]any{"upload_url": url, "object_key": key}) } func (s *Service) ConfirmUpload(ctx context.Context, userID, key string) error { info, err := s.store.Head(ctx, key) if err != nil { return err } if info.Size == 0 || info.Size > maxAvatarBytes { return fmt.Errorf("invalid avatar size") } if _, ok := allowedTypes[info.ContentType]; !ok { return fmt.Errorf("invalid avatar type") } return s.db.SetAvatarKey(ctx, userID, key, info.ETag) } The confirm transaction should replace the old key only after the HEAD checks pass. Delete the old object asynchronously after the new row is committed; a retryable cleanup job is safer than making a profile update wait on two remote deletes. Keep a metric for presigned uploads that never reach confirmation, because that ratio exposes abandoned mobile uploads and oversized-file retries earlier than a generic request counter.
Capacity, residency, and lifecycle are one design
Estimate origin load as peak page views multiplied by avatars per page, then multiplied by one minus the cache hit ratio. For a database blob, run the same estimate against connection occupancy and read I/O, not just bytes per second. Put a separate avatar SLO beside the authentication SLO, and define a fallback such as generated initials; an image timeout should not block login.
For EU and US users, make residency a routing decision rather than a filename convention. Store the chosen region with the object key, write each region to its approved bucket or cluster, and reject cross-region reads in the authorization layer. The exact legal classification still belongs with your privacy counsel; I'm not sure a storage topology alone answers every transfer question, so document the decision and the evidence that would change it.
Lifecycle rules should cover abandoned multipart uploads, old versions, and objects no longer referenced by a row. A periodic reconciliation job can compare keys to database references, but it must be idempotent and bounded so cleanup cannot become a second outage. Object stores commonly expose these controls as policy rather than application cron, which reduces code you have to page on.
I've seen teams postpone this arithmetic because the first release has only a few users. That is reasonable for a prototype, but the migration cost is not zero: keys get copied, cache headers change, and old URLs can remain in clients. Record the ownership, region, maximum object size, and restore test date in the runbook on day one. The document is cheap; reconstructing intent during a restore is not.
Where this recommendation stops
Object storage is unsuitable when the deployment is deliberately offline, the corpus is tiny, and a database transaction must atomically include the bytes; in that case, a database blob may be the simpler audited choice. Local disk remains defensible for a one-instance experiment with a tested external backup and no expectation of horizontal scaling.
The catch is that both exceptions have a sharp transition point. The disk design needs a migration before the second writable instance. The blob design needs one before its backup window or connection pool threatens the primary SLO. Put those triggers in the roadmap now, while the data set is still small enough to move.
