Short answer: keep the upload private and immutable, derive a small declared set of avatar sizes from that original with Sharp, and publish the set only after every object has passed tenant and content checks. The hard part is not resizing a square image. It is preventing one marketplace customer from receiving another customer's asset while a retry, deletion, or slow worker is in flight.
That is the trade-off I would put in front of a platform team: precompute predictable derivatives and spend some storage, or resize on demand and put image CPU, authorization, and cache invalidation on every read. For authenticated customer reports and profile surfaces, the first option gives a cleaner SLO boundary. It also gives us a concrete unit to test: one tenant, one avatar generation, one original, and the required derivatives.
Start with the tenant contract
Treat the original as the source of truth, not as a convenient 256-pixel intermediate. The worker should normalize orientation, apply the product's crop rule, and create a fixed set such as 64, 128, and 256 pixels. Each output should be generated from the original in the same job. This preserves the ability to change the crop policy later without repeatedly degrading an already compressed image.
The object key needs the tenant boundary before any user-controlled identifier. A layout like tenants/acme/avatar_sets/v17/original and tenants/acme/avatar_sets/v17/128 makes the intended ownership visible in logs, review, and cleanup jobs. The database record should contain the tenant ID, subject ID, generation ID, crop-policy version, and derivative metadata. It should not rely on a mutable current object path as its only pointer.
The generation ID is important. A second upload can finish before the first one, and a retry can finish after both. Build each generation off to the side, then update the subject record only after the complete set is present. A conditional database update should include the expected current generation, so an old worker cannot publish over a newer upload.
The contract I want in code review is boringly precise: a caller supplies a tenant and subject identity, the service resolves the current generation from that tenant-scoped record, and only the service constructs the storage key. If a report renderer receives tenant=acme but a key copied from tenant=globex, the renderer must not get a second chance to make that combination valid. The authorization decision precedes key construction. Never let an untrusted client choose the storage key as a query parameter. I would record the rejected request with a correlation ID and the tenant boundary that failed, while avoiding the raw object key in user-visible errors. That gives the on-call engineer enough context to investigate without turning logs into an inventory of customer assets.
Publish last.
Here is the small piece of policy I keep close to the worker. It does not pretend to be an object-storage SDK; it makes the names and completeness invariant explicit, which is the part that belongs in application code regardless of the storage implementation.
package avatar import ( "fmt" "path" ) var requiredSizes = []int{64, 128, 256} type AvatarSet struct { TenantID string SubjectID string Generation string Original string Variants map[int]string } func NewAvatarSet(tenantID, subjectID, generation string) AvatarSet { base := path.Join("tenants", tenantID, "avatar_sets", generation) variants := make(map[int]string, len(requiredSizes)) for _, size := range requiredSizes { variants[size] = path.Join(base, fmt.Sprintf("%d", size)) } return AvatarSet{ TenantID: tenantID, SubjectID: subjectID, Generation: generation, Original: path.Join(base, "original"), Variants: variants, } } func Ready(set AvatarSet, stored map[string]bool) bool { if !stored[set.Original] { return false } for _, key := range set.Variants { if !stored[key] { return false } } return true } The real worker still has to validate the upload before Sharp decodes it: enforce an allowed content type, a byte limit, and a pixel-dimension limit; reject a filename as an authority; and record the resulting content type and dimensions. Those limits protect the transform queue from images that are small on disk but expensive to decode. I would also cap worker concurrency based on observed peak memory, not merely CPU count.
Keep a generation ledger beside the objects
Consider a bounded failure drill: the original and the 64-pixel derivative are stored, the worker is interrupted, and a request reads the database pointer before the 128- and 256-pixel objects exist. The visible result is a broken avatar, but the more serious variant is a pointer containing a key from the wrong tenant because an unscoped cache or cleanup query reused a subject ID.
The invariant is stronger than “the thumbnail eventually exists”: a published generation must be complete, belong to the requested tenant, and be authorized by the same database row that selected it. A read path should load the tenant-scoped record, verify that the requested size is in the declared set, and mint access only for the selected key. Never accept a storage key supplied directly by the browser.
I would test this as a matrix, not a happy-path integration test. Upload two similarly named files under different tenants; retry a transform after a timeout; delete a subject while a job is queued; publish two generations in reverse completion order; request a size outside the allow-list; and replay an expired download URL. A request that returns a valid image is still a failure if it returns the valid image for the wrong tenant.
One response deserves explicit handling. If a conditional publication loses its race and the database reports HTTP 412, the worker should discard its publication attempt, retain enough state for diagnosis, and let the newer generation remain current. It should not retry the same stale update forever. I've seen teams spend more time tuning retry backoff than defining which write is allowed to win; the latter is the actual correctness rule.
The job should be idempotent. Deterministic keys under a generation let a retry overwrite or confirm the same intended derivative, while the database generation check prevents an obsolete job from becoming current. Orphaned objects are expected after interrupted work, so cleanup must require both an age threshold and a query proving that no live record references the generation. A broad prefix delete is an outage-shaped maintenance tool.
How should Node.js Sharp crop and resize an avatar thumbnail for private storage?
For an authenticated customer, the application can stream an object after checking authorization, or it can issue a short-lived presigned URL after checking authorization. Streaming gives tighter control over revocation but consumes application bandwidth and connection capacity. Presigning keeps the bytes off that path, but the URL remains a temporary bearer capability until it expires. Neither choice removes the tenant check that happens before issuance.
Use a separate policy for originals and derivatives. An original may need a staff-only download path, while a 128-pixel derivative can be available to the customer's authenticated report view. The browser should never receive a storage credential. If the application streams the original, set Content-Disposition deliberately: inline display and attachment download are different browser behaviors, as documented by MDN.
Private storage is not a universal fit. It is not suitable when the requirement is an openly cacheable public asset URL, and presigned delivery is a poor match for a client that cannot refresh credentials during a long-lived session. Stick with a proxy when revocation must be immediate and the application can carry the bandwidth; use direct delivery when the access window and cache behavior are explicit. I'm not sure which expiry is correct without the marketplace's session and revocation policy, so that value belongs in an access review rather than in a copied code sample.
Keep it explicit.
Budget the pipeline before choosing its storage plane
The image library is the easy part. The platform decision assigns responsibility for credential rotation, retention, restore tests, orphan cleanup, access logs, and regional recovery. A managed object store may reduce operational work but impose provider-specific semantics; self-hosting may improve control while adding patching, capacity, and on-call duties. Compare those responsibilities against the team's error budget.
| Choice | Evaluate it for | Questions that can reject it |
|---|---|---|
| Managed object storage | A team that wants durable private objects and a documented HTTP or SDK interface | Does it provide the tenant-facing authorization, retention, restore, and regional behavior the SLO requires? |
| Self-hosted object storage | A team with operators and a clear reason to own the storage plane | Who patches it, tests recovery, limits blast radius, and handles disk or node loss? |
| Application proxy over storage | Workloads needing request-time policy and immediate revocation | Can the application afford the bandwidth, latency, and connection load? |
| Direct presigned delivery | Workloads with predictable short access windows and high byte volume | What is the expiry policy, and how are revoked sessions handled? |
Price should be a line in the capacity worksheet, not the architecture. Count original bytes, derivative bytes, object count, request volume, retained generations, and peak transform memory. If an account updates its avatar ten times, retention multiplies the storage footprint even though the current profile shows one image. Queue lag and transform duration then tell us whether the chosen worker pool can keep up with that churn. I would put the expected updates per tenant, retention days, largest accepted pixel dimensions, and peak worker concurrency in the same worksheet, because each one changes either the number of objects, the amount of retained data, or the memory pressure during decoding. The worksheet is useful only when it is connected to an SLO: a queue-age target, a maximum publication delay, and an explicit recovery objective. Without those values, a storage estimate is just arithmetic detached from the failure the team is trying to prevent.
When should this design give way?
Start with a small cohort and instrument the full generation lifecycle: accepted uploads, rejected content, per-size completion, publication conflicts, authorization denials, expired URL requests, retry counts, cleanup candidates, and cross-tenant access-test failures. The last metric should be zero, but it should still exist; an unmeasured invariant is a hope.
Measure p95 and p99 transform duration by input dimensions, not just by compressed file size. Image decoding is sensitive to pixels. Alert on queue age and incomplete-generation age, then rehearse the cleanup path with a generation that has no database reference. Also verify that a deleted customer cannot use an already-issued URL beyond the policy window you selected.
Three sizes are a starting policy, not a law. If the UI has a dense list or retina display, add sizes only after measuring the request pattern and storage cost. If arbitrary user-selected crops are a core feature, persist crop coordinates and a policy version, because the original alone cannot explain how a particular rendition was framed.
The decision rule is narrow: retain originals privately, derive fixed sizes asynchronously, namespace every key by tenant and generation, and publish through an authorization-aware database pointer. Change that rule when public caching, immediate revocation, or regulatory retention becomes the dominant requirement. Your mileage may vary, but the invariant should not.
