Articles / Privacy

  • privacy
  • databases
  • postgresql
  • sqlite
  • uk-gdpr
  • go

A Deleted Chat Message Is Usually Just a Flag on the Server

You tap "delete" on a message and it vanishes from the screen. On the server, in most designs, almost nothing has happened to the text itself. Often a boolean flips, and the bytes sit exactly where they were.

This post follows one message through a typical chat backend and lists the places a copy survives. None of it is sinister; most of it is ordinary engineering. It is still worth knowing before you promise anyone that "deleted" means "gone".

Layer one: the delete is a soft delete

The most common implementation is an UPDATE, not a DELETE. It is popular because other people's clients need to render "this message was deleted" in the right place in the thread.

// What most "delete message" handlers actually do.
_, err := db.ExecContext(ctx,
	`UPDATE messages
	    SET deleted_at = now()
	  WHERE id = $1 AND sender_id = $2`,
	msgID, userID)
if err != nil {
	return fmt.Errorf("soft delete message %d: %w", msgID, err)
}

The body column is untouched. The API simply stops returning it. A bug in a new endpoint, an admin tool or an export job can bring it straight back.

The fix is to overwrite the content at the same moment, and keep only the tombstone:

_, err := db.ExecContext(ctx,
	`UPDATE messages
	    SET deleted_at = now(), body = NULL
	  WHERE id = $1 AND sender_id = $2`,
	msgID, userID)

That is better, but it only fixes the live row. Read on.

Layer two: a real DELETE still leaves the bytes

Even a proper DELETE does not scrub storage. The database marks the space reusable; it does not zero it.

  • In PostgreSQL, an updated or deleted row version becomes a dead tuple. VACUUM makes the space reusable, but it generally does not return it to the operating system or wipe it. The old text can sit in the table file until something overwrites it.
  • In SQLite, deleted content stays in free pages unless you enable PRAGMA secure_delete (or run VACUUM, which rebuilds the file).

Notice the catch with my body = NULL fix above: in PostgreSQL an UPDATE writes a new row version and leaves the old one behind as a dead tuple. The old message text is still in the heap page. You overwrote the logical value, not the physical bytes.

Quick detour: the write-ahead log remembers everything

Hang on, it gets stranger. Before PostgreSQL changes a data page, it writes the change to the write-ahead log, so a crash can be replayed. That log contains the row contents.

So the original INSERT of the message is in the WAL, and it stays there until the segment is recycled or archived. If you archive WAL for point-in-time recovery, the archive holds every message ever sent, in order, which is exactly what it is for.

Streaming replicas receive the same log. Each replica has its own copy of the dead tuple, on its own schedule of vacuuming.

Layer three: everything derived from the message

A chat message is rarely stored once. Count the other places it ends up:

  • a full-text search index (Elasticsearch, OpenSearch, Postgres tsvector) holding the tokens
  • a Redis or in-memory cache of recent messages per room
  • a push notification payload sent via a third-party service
  • link-preview and attachment storage, often keyed by URL or content hash rather than message ID
  • application logs, if anyone ever logged a request body
  • an analytics or moderation pipeline that copied the text for classification
  • nightly backups and snapshots of all of the above

Your handler deleted one row. Seven other systems never heard about it. This is the same shape of problem as an erasure request that only reaches the primary database.

Layer four: the other end of the conversation

"Delete for everyone" is a request, not a guarantee. The recipient's device already decrypted and stored the message. Their client can honour the delete, or not; a modified client, a screenshot or a notification log on their phone will keep it regardless.

With end-to-end encryption the server cannot read the text, which helps with the server-side copies. It still holds ciphertext and metadata (who, when, how big), and it cannot reach into the recipient's device to remove anything.

What UK GDPR says about this

Article 17 of the UK GDPR gives individuals a right to erasure in certain circumstances; it is not absolute, and it depends on the lawful basis and the context. Article 5(1)(e) separately says personal data should not be kept longer than necessary for its purpose.

On backups, the ICO's published position is the practical one: you need not instantly rewrite every backup, but you should be clear with the person about what happens, and make sure the data stays "beyond use" until the backup is overwritten. That is regulator guidance, not a statute, and I read it as a floor rather than a comfort. Check the current text on the ICO page before relying on it.

The honest wording for a privacy notice is therefore something like "removed from your view immediately, purged from live systems within N days, from backups within M days". Vague promises of "permanent deletion" are the thing that gets you in trouble.

Making deletion mean something

You cannot make it perfect, but you can make it defensible. A reasonable checklist:

  1. Overwrite or null the content on delete, keeping only a tombstone with an ID and timestamp.
  2. Run a purge job that hard-deletes tombstones after a stated retention period.
  3. Send the delete to the search index, cache and any queues, and test that it arrives.
  4. Keep message bodies out of logs; a redaction bug there undoes everything above.
  5. Set a finite WAL archive and backup retention, and write the numbers down.
  6. For high-sensitivity data, encrypt each room or user with its own key; destroying the key makes every stray copy unreadable.

The last item is the cleverest, and it is often called crypto-shredding. Every copy in a backup or replica is ciphertext, so deleting one small key does what you could never do by chasing files. It only works if the key is stored and backed up separately from the data, and if the plaintext never leaked elsewhere first.

Before building any of this, test what you already have. Delete a message in a staging system, then run strings over the data directory and the latest backup. What turns up will tell you how much of that checklist you need.