← Liana Grigory
Insights

Deleting a user account is a distributed transaction

The auth record is the last thing you delete, not the first. Everything hard about account deletion happens before that call, in stores that cannot roll back together.

By Liana Grigory · 10 September 2026 · 10 min read

Deleting a user account looks like a one-line operation. In Firebase it very nearly is: admin.auth().deleteUser(uid) and the account is gone. That call is also the least important part of the job, and treating it as the whole job leaves a system that has told the user their data is deleted while most of it is still sitting there.

I have built this flow more than once now, and the useful framing is that account deletion is not a feature. It is a distributed transaction across systems that do not share a transaction manager, performed on behalf of someone who has already decided they do not trust you, and with a legal clock attached. Every one of those clauses changes the design.

The three stores, and why the auth record goes last

A signed-in user in a typical Firebase application exists in at least three places, and probably four.

  • Firebase Authentication holds the identity: the uid, the email, the provider links, and any custom claims.
  • Firestore holds the profile document and everything keyed to that uid, which is almost never just one collection.
  • Cloud Storage holds whatever the user uploaded, under paths that usually encode the uid.
  • Third parties hold whatever you sent them — a payment processor, an email platform, an analytics tool, a support inbox.

The instinct is to delete the auth record first, because that is the one that feels like the account. It is exactly the wrong order.

The moment the auth record is gone, the ID token is worthless, the security rules that let the user reach their own documents evaluate against a null request.auth, and any client-driven cleanup that had not finished is now unauthorised. Worse, the uid is the only key you had. If your deletion routine crashes halfway, you have orphaned data and no longer any identity to associate it with. It is not deleted and it is not findable, which is the worst of both.

Delete the auth record last, as the commit step of a sequence that was already complete.

Ordering that survives a crash

Since there is no transaction spanning Auth, Firestore and Storage, the design has to assume the process dies at the least convenient moment. The property to aim for is not atomicity, which is unavailable, but idempotent resumability: running the sequence twice must be harmless, and running it again after a crash must finish the job.

The order that gives that:

  1. Re-authenticate. Firebase will refuse a delete on a stale credential anyway, and that refusal is a feature. Deletion is irreversible and destructive, so it belongs in the same category as changing a password or a payment method: it requires a fresh proof that the person at the keyboard is the account holder, not someone who found an unlocked laptop.
  2. Write a deletion marker on the user's own document — a status field and a timestamp. This is the only step whose purpose is bookkeeping, and it is what makes everything after it resumable. It also gives the interface something honest to display if the user closes the tab.
  3. Delete or anonymise the dependent data, in dependency order, leaf-first.
  4. Delete the storage objects.
  5. Delete the profile document.
  6. Delete the auth record.

Step three is where the real work is, and where the surprises live.

The data you cannot simply delete

"Delete everything belonging to this user" is a satisfying sentence and an incoherent instruction, because a lot of the data does not belong solely to them.

In a marketplace or a multi-tenant product, a user's records are entangled with other people's records by design. A message has a sender and a recipient. A booking has a client and a provider. An invoice has a payer and a payee, and a legal retention period attached to it. Deleting the sender's copy of a conversation while leaving the recipient's is not deletion; deleting both is deleting somebody else's data on the say-so of a person who does not own it.

The workable distinction is between personal data and transaction records.

  • Personal data — the profile, the photo, the phone number, the address, the free-text notes, the uploads — is deleted.
  • Shared or legally retained records are anonymised in place: the uid reference is replaced with a tombstone, the display name becomes a neutral label, and every personal field on that record is cleared. The counterparty keeps a coherent history; the departing user keeps nothing identifying in it.

This has to be decided per collection, in advance, and written down. The thing that makes it hard is not the code. It is that the answer differs by collection and nobody can derive it from the schema — you have to know what each record is for.

Storage is the part that gets forgotten

Firestore deletions are visible: a document disappears from a console you look at. Storage objects are not, and they are the single most commonly orphaned artefact in an account deletion flow.

Two properties of Cloud Storage make this worse than it looks. There is no recursive delete on a prefix — "folders" do not exist, so you list objects under a prefix and delete them one at a time. And the listing is paginated, so a naive implementation that deletes the first page and declares victory will quietly leave everything after it.

The design decision that prevents most of this pain is upstream of the deletion code: put every user-owned object under a single uid-scoped prefix, from the first day. If uploads are scattered across several top-level buckets and paths, deletion needs a manifest of everywhere the user might have written, and that manifest will fall out of date the first time somebody adds a feature.

Storage is also where the cost argument and the privacy argument point the same way. Orphaned objects are billed monthly, for ever, for data nobody can reach and nobody is allowed to hold.

What the interface has to say, and what the law expects

The engineering is half of it. The other half is that deletion is one of the few product surfaces where regulators, app stores and users all have explicit expectations, and they broadly agree.

  • It has to be reachable from inside the product. Not an email address to write to, not a support ticket, not a form that opens a case. Both major app stores now require an in-app account deletion path for apps that support account creation, and the same principle sits behind the consumer-protection rule that cancelling should be no harder than signing up.
  • It has to be honest about scope. If invoices are retained for tax purposes, say so on the confirmation screen, with the reason and the period. A user who is told "everything is deleted" and later receives a receipt has been misled, and that is a worse outcome than the disclosure would have been.
  • It has to distinguish deactivation from deletion, and not quietly substitute the first for the second. Both are legitimate; presenting one as the other is not.
  • It should confirm when it is finished, not when it has started.

A grace period — the account is disabled immediately, the data is destroyed after some days — is a reasonable design, and it genuinely does prevent regret. It is only reasonable if it is disclosed on the screen where the user confirms, and if there is a scheduled job that actually executes it. An undisclosed grace period is just a delay you did not mention. A disclosed one with no job behind it is a promise to delete that nothing keeps.

Doing it without a server

The obvious implementation is a privileged backend function holding a service account. It works, and on a portfolio built to stay inside free tiers it is also the most expensive way to solve the problem, because it introduces a deployed, billed, permanently-privileged component for an operation that runs rarely.

The alternative is to let the client do it with the user's own credentials, and to let the security rules be the enforcement. The user is authenticated, the data is theirs, and rules already scope every path to request.auth.uid. A client that can read its own documents can delete them, and a rule that permits it cannot be tricked into permitting somebody else's, because the uid in the token is not something the client controls.

The honest limits of that approach are worth stating plainly. Anonymising a shared record means writing to a document the departing user does not own, which rules will correctly refuse unless you carve out a narrow, field-limited permission — and widening a rule to make a feature work is how deny-by-default quietly stops being deny-by-default. Deleting the auth record from the client requires a fresh credential. And a client-driven process can be abandoned mid-flight when someone closes the tab, which is precisely why step two writes a marker.

The design I have settled on is a hybrid with the boundary drawn at ownership: the client deletes everything it owns, under rules that already constrain it; the small number of shared-record anonymisations are expressed as a narrowly-scoped rule that permits exactly one field transition and nothing else; and the deletion marker makes the whole thing resumable. No always-on privileged service, no service account key in circulation, and no broad rule added to make it convenient.

How to know it worked

The test that matters is not that the function returned. It is that nothing is left.

Create an account through the auth REST API rather than the sign-up screen, so no verification mail is sent to an address that does not exist. Have it write into every collection and upload into every storage prefix the product supports. Run the deletion. Then go looking, with admin credentials, for anything still keyed to that uid: each collection, each storage prefix, the auth record itself. Assert absence, one by one. Then run the deletion a second time on the same uid and confirm it completes cleanly rather than throwing, because a resumable process that cannot tolerate being resumed is not resumable.

The failure this catches is always the same one, and it is always a collection added six months after the deletion routine was written by someone who did not know the routine existed. Which is the real lesson: the deletion path is not a feature you finish. It is a piece of infrastructure that every new collection has to be registered with, and the only reliable way to keep it honest is a test that enumerates the collections and fails when it finds one nobody handled.

Written by Liana Grigory, Entrepreneur and Software Engineer, from work on The Care Royal, Tegula Stone and Unified Savers. Everything above describes decisions actually made on those systems, including the ones that turned out to be wrong.

Home · Terms of Use · Privacy Notice