“Users can delete their account” reads like a checkbox.
One button, one confirmation dialog, one DELETE
statement. I budgeted it accordingly, and then spent a multiple of
that budget building what it actually is: a small distributed
workflow with scheduling, compensation, and its own failure sweep.
If you're building anything with users in Europe, deletion under GDPR isn't optional, and doing it honestly is harder than it looks. Here's what it turned into for me, and why every shortcut I considered was wrong.
There is no row to delete
The first fantasy to die is “just delete the row.” On this platform, one user's data lives in at least four places:
- The document store — the profile itself, plus documents the user created over time.
- The graph database — a node wired into other people's social worlds: connections, interactions, relationships that other users can see from their side. Running two databases with different shapes is the right call for reads, and it doubles the surface area of every erasure.
- Object storage — uploaded media: avatars, images, files with their own lifecycle and CDN caching in front of them.
- Denormalized copies — and this is the one that gets you. For read performance, a user's display name and avatar are stamped onto things they've touched all over the system. That's the right call at read time and a scavenger hunt at deletion time.
No single transaction spans those systems. Deletion is therefore not an operation — it's a process, with all the process problems: partial failure, retries, ordering, and observability.
The grace period changed the architecture
Before touching the how, one product-shaped decision reshaped everything technical: deletion has a 30-day grace period. Ask to delete, and the account is immediately deactivated but not destroyed; change your mind within 30 days and it comes back intact.
I didn't add this to be nice (though support-wise, it is — rage-quits and compromised accounts are real). I added it because it converts an irreversible distributed operation into a reversible one, and reversible operations are an order of magnitude safer to operate. During grace, nothing is destroyed, so a bug in the scheduling path destroys nothing. The truly destructive work happens later, in one well-audited purge path, executed by a scheduled job with no human in the loop to fat-finger it.
That means deletion is a state machine, not an action. An account moves through active → pending deletion → purged, or back from pending to active on cancel. Every part of the system had to learn what “pending deletion” means: the account can't be interacted with, doesn't show up where it used to, but its data still exists. Which immediately raises the restore question — deactivation zeroes out those denormalized copies, so cancelling doesn't just flip a flag, it has to rehydrate everything that was zeroed. Restore is a first-class path with its own bugs, not an afterthought. I know because I shipped one of those bugs and got to find it in beta.
Tombstones, or: the graph remembers
The graph database poses a special problem. Other users' worlds reference the deleted node — you can't just vaporize it without deciding what their side of the relationship now means.
The answer is a tombstone: the node's personal data is stripped and the node is reduced to an anonymous marker that keeps referential integrity intact. Other users see a generic deleted-user placeholder instead of broken references or — much worse — someone else's data getting misattributed. The GDPR obligation is to erase personal data, and a tombstone with nothing personal in it satisfies that while keeping the graph structurally sane.
Tombstones also close a subtle security hole. If deleted identifiers are ever recycled or their old routes keep resolving, you can end up serving one user's residue to another. Making “deleted” an explicit, permanent, empty state — rather than an absence — is what lets every code path handle it deliberately instead of accidentally.
The purge, and the sweep behind it
After 30 days, a scheduled job performs the real purge: documents deleted, graph node tombstoned, storage objects removed, the auth record destroyed. Each target system is handled by its own idempotent step, because the job will be interrupted someday and must be safe to re-run. Idempotent here means every step is phrased as “ensure X no longer exists” — a form that can't fail on the second attempt just because the first attempt half-succeeded.
And because every multi-step process eventually strands something in the middle, there's one more component that I'd argue is the least optional of all: a sweep for stuck states. If an account sits in pending deletion past its window plus a healthy margin, that's an invariant violation, and something needs to notice — the difference between finding out from your own monitoring and finding out from a regulator's letter. A user who asked for deletion in March and is still lurking in your database in July, because a purge job died and nothing retried it, is exactly the failure mode this entire design exists to prevent.
The takeaway
If deletion is on your roadmap as a small task, re-estimate it. The honest version is: a state machine with a grace period, a rehydrating restore path, tombstones wherever other users' data references the deleted one, an idempotent multi-system purge, and a sweep that hunts for stuck accounts. None of the pieces are individually hard. What's hard is noticing that all of them are required — and the best time to notice is before your data model has metastasized across four storage systems. Deletion is the feature that audits your entire architecture, whether you scheduled the audit or not.