Deletion is only convincing when the system can name every image it owns, remove each copy, and prove that those objects are no longer readable. For a fintech upload flow that creates responsive thumbnails and sends images through moderation, that means treating erasure as a tracked workflow rather than an unlink() call inside an Express route.
TL;DR: keep an asset manifest per user, freeze new image writes when account closure begins, delete every original, derivative, and moderation copy from that manifest, then verify absence before marking the request complete. Retain a minimal audit record about the operation, not the deleted image or its object key.
| Approach | Pick this when | Moderation coverage | Main failure mode |
|---|---|---|---|
| Delete keys stored on the user row | There is exactly one avatar and no derived copy | Poor once moderation or resizing creates another object | Untracked thumbnails survive |
| Delete everything under a user prefix | Every image copy is guaranteed to remain under one stable namespace | Good if scanners and workers obey the namespace | Shared or moved objects break the assumption |
| Delete from an append-only asset manifest | Originals, responsive sizes, and moderation artifacts have different lifecycles | Explicit and testable | A producer can omit registration |
| Combine manifest deletion with namespace reconciliation | The erasure risk justifies a second discovery path | Strongest coverage of known and stray copies | More reads, state, and operational work |
The practical default for this scenario is the third row, plus a scheduled prefix reconciliation where the storage layout permits it. The manifest is the source of work. Reconciliation is the alarm for a broken producer.
How should Node.js delete user images during account deletion?
An uploaded avatar is rarely one object after processing. A typical fintech flow accepts an original, validates its media type, generates perhaps 64x64, 256x256, and 512x512 variants, and records a moderation result. A scanner may temporarily hold another copy. A retry can create a replacement while an older derivative remains addressable.
The graph keeps growing.
Diagram in words: upload request -> quarantine object -> moderation decision -> original asset -> three responsive thumbnails -> delivery cache. Erasure has to walk that graph in reverse. The user row points at the current avatar, but it does not necessarily describe the graph.
This matters under GDPR because Article 17 defines a right to erasure, subject to listed exceptions. It does not define an object-store API or promise that one database delete covers derived files. The engineering obligation is therefore to map the legal decision onto the system's actual copies. Article 30 also makes records of processing activities relevant; it is not a license to retain erased content as audit evidence.
The moderation boundary deserves special attention. A boolean such as avatarApproved proves a decision happened. It does not prove that the quarantine object, a rejected-image sample, or a reviewer preview was removed. Put those objects in the same manifest, each with a purpose and retention class. Then an erasure worker can cover the full set without guessing how a moderation tool happened to name its files.
Build the inventory before the delete path
Start with one record per stored object, not one record per logical avatar. The useful fields are an opaque asset ID, subject ID, object key, role, lifecycle state, and timestamps. Image dimensions and detected type can help processing, but do not keep metadata merely because it is available. MDN's image format guide is a useful format reference; file extensions alone are not a trustworthy content check.
There is a sharp trade-off here. Prefix deletion is pleasantly small, while a manifest adds a write to every producer. The extra write wins when moderation coverage is the decision axis because it makes ownership explicit across quarantine, approved originals, thumbnails, and reviewer artifacts. A database constraint should reject an asset transition to ready unless its object record exists. That turns “remember to register the thumbnail” into an invariant.
Freeze first.
Once an account enters erasure_pending, upload authorization and background thumbnail generation must refuse new work for that subject. Otherwise the worker can verify an empty manifest at 10:02 and a delayed resize job can write a fresh thumbnail at 10:03.
The ordering is intentional:
- Create an erasure operation with a stable request ID.
- Block new writes for the subject.
- Snapshot all active manifest entries, including moderation objects.
- Request deletion using idempotent storage operations.
- Verify every object is absent, then redact identifying manifest fields.
- Mark the operation complete with counts and timestamps.
Do not wrap remote object deletion and a database update in a pretend distributed transaction. It cannot be atomic. Persist checkpoints and retry.
A minimal Node.js erasure worker
The interface below keeps Express, storage, and persistence concerns separate. remove() must treat an already absent object as success. exists() must perform an authoritative metadata read appropriate to the storage system, rather than trusting an application cache.
type AssetRole = "original" | "thumbnail" | "moderation";
type Asset = {
id: string;
subjectId: string;
objectKey: string;
role: AssetRole;
};
interface ObjectStore {
remove(objectKey: string): Promise<void>;
exists(objectKey: string): Promise<boolean>;
}
interface AssetRepository {
beginErasure(subjectId: string, requestId: string): Promise<void>;
listForErasure(subjectId: string): Promise<Asset[]>;
recordDeleted(assetId: string): Promise<void>;
recordVerification(assetId: string, absent: boolean): Promise<void>;
finishErasure(
subjectId: string,
requestId: string,
summary: { discovered: number; verifiedAbsent: number }
): Promise<void>;
}
class VerificationError extends Error {
constructor(readonly remainingAssetIds: string[]) {
super(`Deletion could not be verified for ${remainingAssetIds.length} assets`);
}
}
async function eraseUserImages(
subjectId: string,
requestId: string,
store: ObjectStore,
assets: AssetRepository
): Promise<void> {
await assets.beginErasure(subjectId, requestId);
const inventory = await assets.listForErasure(subjectId);
for (const asset of inventory) {
await store.remove(asset.objectKey);
await assets.recordDeleted(asset.id);
}
const checks = await Promise.all(
inventory.map(async (asset) => ({
asset,
absent: !(await store.exists(asset.objectKey)),
}))
);
for (const check of checks) {
await assets.recordVerification(check.asset.id, check.absent);
}
const remaining = checks
.filter((check) => !check.absent)
.map((check) => check.asset.id);
if (remaining.length > 0) {
throw new VerificationError(remaining);
}
await assets.finishErasure(subjectId, requestId, {
discovered: inventory.length,
verifiedAbsent: checks.length,
});
}
The HTTP layer should acknowledge the workflow, not hold a connection open while storage calls and retries run. Returning 202 Accepted is appropriate when the request has been accepted but processing is incomplete, as defined by HTTP Semantics (RFC 9110). Authentication and authorization still happen before the operation is created.
import express, { type Request, type Response } from "express";
import { randomUUID } from "node:crypto";
const app = express();
app.delete(
"/account/images",
async (req: Request, res: Response): Promise<void> => {
const subjectId = req.authenticatedUser.id;
const requestId = randomUUID();
await erasureQueue.enqueue({ subjectId, requestId });
res.status(202).json({ requestId, status: "pending" });
}
);
The queue consumer calls eraseUserImages. In production, bound concurrency so verification reads cannot overwhelm storage, and use retry delays with jitter. The request ID is the idempotency key: a repeated authenticated request should return the existing operation rather than launch a competing erasure.
Four checks make the evidence useful
Count four distinct things. First, discovered_assets tells you what the manifest knew. Second, delete_attempts exposes retry pressure. Third, verified_absent records successful absence checks. Fourth, reconciliation_findings reveals objects found under the subject namespace but missing from the manifest. Those are operational facts, not a copy of the user's data. Logs should carry the erasure request ID and opaque asset IDs. Avoid object keys when they contain a user identifier, filename, or account number. Never log image bytes, signed URLs, moderation previews, or free-form failure payloads from upstream systems. Alert on states, not on a single failed call: a transient deletion error can be retried, while an operation stuck in verifying beyond the service's documented completion objective needs attention. Also alert when reconciliation finds any unregistered object, because that indicates a producer escaped the manifest contract. A dashboard that only charts successful worker jobs can look healthy while one entire class of thumbnails is invisible.
Miss one, and the claim fails.
Test the uncomfortable cases. Seed one original, three responsive variants, and one moderation object. Inject a deletion failure on the third object, retry the same request ID, and assert that all five are eventually absent. Then race a delayed thumbnail job against beginErasure and confirm the write is rejected. Finally, create an unregistered object under the test subject's namespace; reconciliation should find it and keep the operation from being presented as fully verified.
Backups are a separate retention system. Immediate selective removal may be unavailable in an immutable backup design, so document the expiry schedule, prevent restoration into active service without replaying erasure tombstones, and include that boundary in the response process. Do not report “all copies deleted” when the evidence only covers primary storage.
Limits of a verified workflow
An absence read proves what the selected storage interface can observe at that moment. It does not prove removal from a recipient's downloaded copy, an unmanaged reviewer device, or a backup outside the inventory. Those boundaries belong in the data map and the erasure response.
Cache invalidation needs its own signal as well. Purge delivery caches after origin deletion, and verify through the public delivery path when that path can serve a stored response independently. Keep the claim narrow: the workflow completed for the systems and copies enumerated by the manifest and reconciliation job.
The hard part is coverage. Make every image-producing component register its output, make account closure stop new production, and measure discrepancies. Then Express is merely the front door to an erasure process whose result can be defended.
Top comments (0)