TL;DR: Moderate an edtech avatar at upload, publish a new opaque asset key only after approval, and replace the profile reference with a tombstone before deleting bytes. On-demand processing is the runner-up for private drafts and rarely viewed images. A versioned URL prevents a replacement from colliding with an older representation; it does not, by itself, make an already shared URL stop loading.
| Decision | Upload-time processing | On-demand processing |
|---|---|---|
| Before an avatar goes live | Approval gates publication | The first read becomes part of moderation |
| Delete behavior | One published key to retire | Derived variants and lazy jobs widen cleanup |
| Read path | Predictable | More branches and cold work |
| Best exception | Rarely justified | Private drafts or genuinely sparse access |
Recommendation: choose upload-time processing for user avatars that classmates, teachers, or guardians can see. It keeps moderation out of the display request and makes publication an explicit state change. The interesting part is not the image resize. It is making every reader stop discovering the retired key.
How do I debug a deleted user avatar that still loads?
Deletion is usually described as one operation, but the system has at least three pieces of state: the profile reference, the stored object, and cached responses keyed by URL. Removing the object touches only one of them. If HTML, JSON, a service worker, or a previously copied URL still names the old key, a request can continue to target that identity.
Start with one question: which exact URL produced the visible pixels? Capture it from the browser request, then compare it byte for byte with the current profile response. Do not begin by clicking a purge button. That hides whether the application is still publishing a stale reference.
The status transition should be monotonic:
- Mark the avatar record as retired and return the neutral placeholder from profile reads.
- Invalidate or replace every application document that contains the retired asset key.
- Retire delivery for that key according to the system's cache policy.
- Delete the original and every derived object.
- Verify both the profile path and the captured old URL.
Order matters. Deleting bytes first leaves a window where profile data still advertises an object that no longer belongs on the page. Switching the reference first makes the user-visible state correct while cleanup proceeds.
No magic purge.
Two criteria decide where moderation belongs
The first criterion is exposure. A public avatar has a high read-to-write ratio, so placing decode, validation, moderation, and transformation on reads repeats control flow where latency is most visible. Upload-time processing pays that complexity once before publication. It can reject an unsupported input, create the approved representation, and commit the profile reference as one deliberate workflow. MDN's image format guide is a useful baseline when defining accepted browser image types; acceptance still needs to be narrower than "anything with an image extension."
The second criterion is deletion reach. Count every durable place that can name or derive an asset: the user row, activity feeds, classroom rosters, notification payloads, generated thumbnails, and caches. A low-glue design stores an opaque asset key in canonical records and constructs URLs at the boundary. That gives deletion tooling one identifier to trace. Storing complete URLs in six payload shapes feels convenient until the host, transform parameters, or asset generation changes.
Benchmark the path you own. Record upload-to-decision latency, publication latency, moderation failures by stage, and the time from retirement to zero application references. Compare p50 and p95 rather than inventing one universal target; derive the acceptance threshold from the product's visibility promise and test it under representative image sizes and formats. Averages are weak here. Keep the raw stage timings.
A small state machine beats cleanup glue
The API needs explicit states, not a nullable string that means five things. This TypeScript sketch keeps the public read path boring and makes an old key impossible to republish accidentally:
type AvatarState =
| { kind: "pending"; uploadKey: string }
| { kind: "published"; assetKey: string; generation: number }
| { kind: "retired"; retiredAssetKey: string; retiredAt: string };
type PublicAvatar = { src: string; generation: number } | { src: null };
function publicAvatar(state: AvatarState): PublicAvatar {
if (state.kind !== "published") return { src: null };
return {
src: `/media/${encodeURIComponent(state.assetKey)}?v=${state.generation}`,
generation: state.generation,
};
}
function retireAvatar(
state: Extract<AvatarState, { kind: "published" }>,
retiredAt: string,
): AvatarState {
return {
kind: "retired",
retiredAssetKey: state.assetKey,
retiredAt,
};
}
The generation changes when a different approved representation is published. It should not be a timestamp appended on every render; that defeats cache reuse and makes request traces noisy. Use the same URL for the same bytes. Use a new key or generation for new bytes.
Retirement is different from replacement. The public serializer above returns no retired URL at all. A background worker can consume retiredAssetKey, enumerate known derivatives, request their removal, and record each result. Retries must be idempotent because partial cleanup is normal in a multi-step workflow. The worker should never restore the profile reference.
For tests, model the race instead of testing only the happy path. Pause cleanup after the profile transaction, request the profile, and assert that it contains no old key. Resume cleanup twice and assert the terminal state is unchanged. Then request the captured old URL through the same delivery path a browser uses. A storage-level "not found" check is insufficient because it skips the cache under investigation.
When is on-demand processing the better runner-up?
Upload-time processing has a real limitation: it spends moderation and transformation work on every submitted image, including one the user never publishes. That trade-off is acceptable for public classroom avatars because the read path stays predictable. It is a poor fit for private drafts, rarely viewed assets, or transformations that cannot be known until a later context chooses them. In those cases, on-demand processing earns its complexity. Avoid letting the first public request silently become the moderation job. Queue the work, expose a pending state, and publish only after the result is approved.
It also changes deletion accounting. A source may have no derivative today and several tomorrow if a delayed job was already queued. Pass the asset's retirement state into the job, check it again before writing output, and make a retired key a terminal result. Otherwise a late transform can recreate data after cleanup reported success.
That race is easy to miss. Add a test that queues a transform, retires the avatar, and then releases the worker. The expected result is no published derivative and no profile reference.
The operational check that closes the loop
A deletion dashboard should answer a narrow question: can any supported application path still resolve the retired asset? Track the canonical reference update, derivative cleanup, delivery retirement, and verification as separate stages. One green "delete succeeded" flag compresses too much state and makes debugging slower.
Keep an audit record with the asset key and stage outcomes, but do not log image bytes or reusable public URLs. Sampled synthetic checks can exercise replacement and retirement in a test account. Run them through profile serialization and the delivery edge, because those are the two surfaces users actually observe.
The final decision rule stays compact: process public edtech avatars at upload, publish by opaque generation, retire the reference before the bytes, and verify the old identity from outside storage. Choose on demand only when avoided work clearly outweighs a larger state machine.
Top comments (0)