“Burn after reading” sounds like the simplest feature in the world. Show the message once, then destroy it. A first-year could write it: serve the content, delete the row, done.
I shipped exactly that. Then I watched it fail in the most invisible way possible — and fixing it properly meant throwing out the obvious implementation entirely.
The bug that isn’t a bug
Here’s the failure. You create a self-destructing note, copy the link, and paste it into Slack (or iMessage, or WhatsApp, or Discord). Before you even hit send, the app helpfully unfurls a little preview of the link. You send it. Your friend opens it a minute later and sees… nothing. “This note has already been read.”
Nobody read it. Or rather — a bot did.
When you drop a URL into almost any modern chat app, the app fetches that URL server-side to build the preview card: title, description, favicon. That fetch is an HTTP GET. And to a naive burn-after-read implementation, a GET is a read. So the preview bot triggers the burn, the content is deleted, and the actual human recipient arrives to an empty page.
The feature works perfectly in every demo, because when you test it, you open the link first. It breaks the moment the link travels through the exact channel people actually use to share things.
The tempting wrong fix
My first instinct was to filter the bots out. Check the User-Agent, keep a list of known preview crawlers — Slackbot, WhatsApp, Discordbot, Telegram, facebookexternalhit — and don’t count their requests as reads.
Don’t do this. It’s a signature arms race you lose slowly:
User-Agent is a suggestion, not a fact. Anything can send any string. You can’t build a security-relevant guarantee on a header the client chooses.
The list is never complete. New apps and link scanners appear constantly — corporate email gateways, antivirus URL checkers, “safe link” rewriters. Miss one and you’re back to burning early.
You get it wrong in both directions. Too aggressive and you block a legitimate client; too loose and a new bot slips through. There’s no version of the list that’s ever finished.
Filtering who is asking is the wrong question, because the answer is unknowable and forgeable.
The behavioral fix
The insight that fixed it: stop asking “is this request from a human?” and start asking “is someone actually looking at this right now?”
A real reader doesn’t just fetch the HTML once. They load the page, and their browser runs the JavaScript, which sends a small heartbeat every few seconds while the page is open — I’m still here, I’m still here. A preview bot does the opposite: it fetches the raw HTML a single time to scrape the meta tags, and leaves. It never runs the JS. It never sends a heartbeat.
So the rule flips. Don’t delete on read. Delete when the heartbeat stops.
The bot fetches the page and vanishes without a single heartbeat, so it never counts as a viewing session. The human opens the page, starts a heartbeat, reads, and closes the tab — and only then, once nothing is watching anymore, does the note burn.
What “read” quietly came to mean
This is the honest part most write-ups skip. Switching to heartbeats changes the guarantee, and it’s worth being precise about how.
Naive burn-after-read promises “destroyed after exactly one fetch.” That promise was always a lie (the bot got the fetch), but it sounds stricter. The heartbeat model promises something different: “destroyed once nobody is watching.” If two people have the link open at the same time, the note survives until both of them leave. There’s a real window where the content is alive after it’s been opened.
That’s a deliberate trade. I’d rather the right person reliably sees the note once, than offer a tighter-sounding guarantee that breaks every time a chat app touches the link first. Correctness over a number that looks good in the marketing copy.
The part nobody mentions: nothing says goodbye
“Delete when the heartbeat stops” has a hole in it. What actually is “stops”?
Browsers don’t reliably fire a clean “I’m leaving” event. A tab crashes. A phone’s battery dies. Someone shuts the laptop lid mid-read. The heartbeat just… ceases, with no final packet announcing it. If deletion depended on catching a farewell signal, those notes would live forever.
So the last viewer leaving can’t be the thing that triggers cleanup. Instead there’s a scheduled sweep: a job that runs every minute, finds any burn-after-read content whose last heartbeat has gone stale — last_seen older than a small threshold — and deletes it, files and all. The same sweep handles anything past its expiry time. Cleanup runs on a clock, not on someone showing up. A note that was opened and abandoned is gone within a minute, whether or not anyone ever visits again.
That inversion — cleanup can’t depend on a visit — turned out to be the load-bearing idea. The heartbeat decides when something is done being read; the sweep is what actually makes deletion happen without relying on the client to behave.
A note on not being able to see any of this
For context, the whole thing sits behind end-to-end encryption: content is encrypted in the browser and the key never reaches the server, so what’s stored is ciphertext. The watch table only ever knows “this slug is being looked at,” never what the slug contains. The heartbeat is pure metadata — presence, not content. Which is the only way I’d want to build presence tracking in the first place.
Burn-after-read is a small feature, but it’s a clean example of a whole category: the naive implementation isn’t slightly wrong, it’s invisibly wrong. It passes every test you’d think to write, demos flawlessly, and fails silently in production for the one reason you didn’t simulate — that the internet touches your link before a person does.
I build this into hidetext.sh, if you want to see it in practice.
Top comments (0)