DEV Community

Cover image for I Built a Chat App That Deletes Itself (Because I Was Bored at 2am)
Rolan Lobo
Rolan Lobo

Posted on

I Built a Chat App That Deletes Itself (Because I Was Bored at 2am)

I'm going to be honest with you.

It started because I watched a spy movie, thought "this message will self-destruct in 5 seconds" was the coolest thing ever, and immediately opened VS Code instead of going to sleep like a normal person.

Seven days, one developer (me, just me, no team, no co-founder, no intern, nobody β€” just me, my laptop, and an unhealthy amount of coffee), I had Burn Chat running.

Here's what it does: you create a chat room with a countdown timer. Share the link. People join. You talk. When the timer hits zero, every single message on every single screen disappears. The server deletes everything. There's no history, no logs, no exports, no "deleted messages" folder. It's just... gone. Forever.

This is the story of how I built it, what I got completely wrong, and the parts I'm actually proud of.

πŸ‘‰ Try it live: bar-rnr.vercel.app/burn-chat


What I Was Actually Going For

The feature list I had in my head at 2am:

  • ⏱️ Set a countdown timer (5 min to 24 hr)
  • πŸ” End-to-end encryption β€” the server should never be able to read messages
  • πŸ”₯ When the timer hits zero, everyone sees a fire animation simultaneously
  • πŸ”— One link to share, no accounts, no sign-up
  • πŸ‘‘ A PIN so the creator can kick people, lock the room, extend the timer
  • 🚫 Absolutely nothing stored on disk or in a database

That last one is important. If I'm going to call this "ephemeral", it had better actually be ephemeral. Not "ephemeral but we keep logs for 30 days for compliance reasons." Actually gone.


The Architecture (Or: What I Drew on a Napkin)

Your Browser                 My Server                  Their Browser
────────────────             ──────────────             ──────────────
Generate keys                                           Generate keys
Send public key  ──────────► store in RAM ────────────► get public key

Encrypt message  ──────────► relay blob   ────────────► decrypt message
(AES-GCM)        server sees ????base64????              (AES-GCM)
                 literally has no idea
                 what it says
Enter fullscreen mode Exit fullscreen mode

The server is a blind courier. It sees base64 blobs going in one WebSocket and coming out another. It has no idea what the messages say. It stores nothing on disk.

This is not a marketing claim. The server genuinely cannot decrypt the messages because it never has the keys.


Part 1: The Backend β€” Python, FastAPI, and Some Questionable Choices

I Used a Dictionary to Store Sessions

# I could have used Redis. I used this.
_SESSIONS: Dict[str, _ChatSession] = {}
Enter fullscreen mode Exit fullscreen mode

Yes. A plain Python dictionary. In memory. On one process.

You might be thinking: "that's not scalable."

You're right. It's also exactly what I needed. Here's my thinking:

If I store sessions in Redis, Redis is a database. Databases write to disk. If data touches disk, it can be recovered. I'm building a feature whose entire selling point is that data CANNOT be recovered. A plain in-memory dict that disappears when the process restarts is a feature, not a bug.

I'm one person. I have one dyno. It works. When I need to scale, I'll figure out sticky sessions + Redis with a EXPIRE key. That day is not today.

Each Session Gets a Timer

async def _countdown_loop(token: str, session: _ChatSession) -> None:
    try:
        while True:
            now = datetime.now(timezone.utc)
            remaining = (session.expires_at - now).total_seconds()

            if remaining <= 0:
                break

            # Coarse ticks when there's plenty of time.
            # 1-second ticks in the final minute for smooth UI animation.
            interval = 1.0 if remaining <= 60 else 10.0
            sleep_for = max(0.05, min(interval, remaining))
            await asyncio.sleep(sleep_for)

            # ⚠️ Always recompute from the wall clock.
            # Never trust sleep() duration β€” it drifts under load.
            remaining = (session.expires_at - datetime.now(timezone.utc)).total_seconds()

            await _broadcast(session, {
                "type": "countdown",
                "seconds_remaining": max(0, int(remaining))
            })

    except asyncio.CancelledError:
        return

    await _destroy_session(token)
Enter fullscreen mode Exit fullscreen mode

The important bit: always recompute remaining from the actual wall clock. Never decrement a counter. Python's asyncio.sleep() can sleep longer than you asked if the event loop is busy. If you accumulate sleep durations, your 10-minute timer ends up being 11 minutes. Recomputing from expires_at - now every tick keeps it accurate.

Burning the Session

async def _destroy_session(token: str) -> None:
    session = _SESSIONS.get(token)
    if session is None:
        return  # already gone, nothing to do

    # Step 1: Tell EVERYONE to show the fire animation.
    await _broadcast(session, {"type": "destroyed"})

    # Step 2: Close every WebSocket connection.
    for participant in list(session.participants.values()):
        try:
            await participant.ws.close(code=1000, reason="Session expired")
        except Exception:
            pass

    # Step 3: Delete from memory. This IS the burn.
    _SESSIONS.pop(token, None)
Enter fullscreen mode Exit fullscreen mode

Order is important here. Broadcast first β€” otherwise you'd close the connections before clients know to show the animation. Everyone sees the fire at the same time because the broadcast goes out in a single loop before any connections are closed.


Part 2: The Crypto β€” The Part That Made My Brain Hurt

I want to be upfront: cryptography is hard. I read a lot. I tested a lot. I still probably got some edge case wrong. But here's what I implemented and why.

Key Generation

Every person who joins generates an ECDH P-256 keypair in their own browser:

const keypair = await crypto.subtle.generateKey(
  { name: 'ECDH', namedCurve: 'P-256' },
  false,         // ← this means the private key CANNOT be exported
  ['deriveKey']
);
Enter fullscreen mode Exit fullscreen mode

See that false? That's extractable: false. The Web Crypto API gives you a hard guarantee: even if some JavaScript on the page tries to export that private key, the browser will refuse. The private key is born in your browser and dies in your browser.

The Key Exchange

Each participant broadcasts their public key to the server. The server stores it (as an opaque blob it can't use for anything) and relays it to everyone else:

// You send this when you connect
ws.send(JSON.stringify({
  type: 'pubkey',
  public_key: await exportPublicKey(myKeypair)
}));
Enter fullscreen mode Exit fullscreen mode

The creator then derives a shared ECDH secret with each participant, generates one AES-GCM-256 session key, and sends each person a version wrapped (encrypted) with their specific shared secret:

async function wrapSessionKey(sessionKey, peerPublicKeyB64, myPrivateKey) {
  // Derive a shared secret using our private key + their public key
  const sharedSecret = await crypto.subtle.deriveKey(
    { name: 'ECDH', public: await importPeerPublicKey(peerPublicKeyB64) },
    myPrivateKey,
    { name: 'AES-KW', length: 256 },
    false,
    ['wrapKey']
  );

  // Wrap the session key with the shared secret
  const wrapped = await crypto.subtle.wrapKey('raw', sessionKey, sharedSecret, 'AES-KW');
  return btoa(String.fromCharCode(...new Uint8Array(wrapped)));
}
Enter fullscreen mode Exit fullscreen mode

The server receives an opaque blob for each recipient and delivers it. It has no idea what's inside. Even if someone hacked my server right now while you were chatting, they'd get base64 they can't do anything with.

Encrypting Messages

Every message gets a fresh 12-byte random IV:

async function encryptMessage(text, sessionKey) {
  const iv = crypto.getRandomValues(new Uint8Array(12));  // fresh every time
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    sessionKey,
    new TextEncoder().encode(text)
  );

  return {
    ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
    iv: btoa(String.fromCharCode(...iv)),
  };
}
Enter fullscreen mode Exit fullscreen mode

Fresh IV per message is non-negotiable. I'll explain why in the mistakes section.

The Session Fingerprint

After key exchange, everyone computes this:

async function deriveFingerprint(sessionKey) {
  const raw = await crypto.subtle.exportKey('raw', sessionKey);
  const hash = await crypto.subtle.digest('SHA-256', raw);
  const bytes = new Uint8Array(hash);
  return Array.from(bytes.slice(0, 3))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');  // something like "a3f9c2"
}
Enter fullscreen mode Exit fullscreen mode

Everyone in the session sees the same 6-character hex code. If you're paranoid, read it out loud to the other person. If your codes match, the key exchange wasn't tampered with. Is it perfect? No. Is it better than nothing? Absolutely.


Part 3: The Frontend β€” React, Fire, and a State Machine

Three Phases, One Component

The entire chat UI lives in one state variable:

const [phase, setPhase] = useState('join');
// 'join'      β†’ name entry, waiting to connect
// 'chat'      β†’ connected, messaging, countdown ticking
// 'destroyed' β†’ fire animation, then... nothing
Enter fullscreen mode Exit fullscreen mode

Simple. No routing. No URL changes. Just a state string that drives which thing you see.

The Burn Animation

The part I spent way too long on and absolutely do not regret:

// BurningAnimation.jsx
// mode="chat" for Burn Chat, mode="file" for file destruction

{mode === 'chat' ? (
  <div className="text-center">
    <Flame size={56} className="text-orange-400 animate-pulse mx-auto" />
    <h2 className="text-2xl font-bold text-orange-400 mt-4">
      Session Burned
    </h2>
    <p className="text-gray-400 text-sm mt-2">
      All messages have been permanently erased. No trace remains.
    </p>
  </div>
) : (
  <div className="text-center">
    <FileX size={56} className="text-red-400 mx-auto" />
    <h2 className="text-2xl font-bold text-red-400 mt-4">
      File Destroyed
    </h2>
  </div>
)}
Enter fullscreen mode Exit fullscreen mode

I reused the same animation component for file destruction and chat destruction, added a mode prop, and now it knows which version to show. The timing, the progress bar, the fade β€” all identical. Different icon, different text, different feeling.

Creator Controls

When you create a session, you get a PIN. Enter it when you connect and you unlock: kick participants, lock the room so no one new can join, and extend the countdown. The PIN is sent over the WebSocket (under TLS) and compared server-side with secrets.compare_digest() β€” constant-time comparison so you can't do timing attacks to guess digits one by one.

Three wrong PINs in 10 minutes and you're locked out. I'm not taking any chances with brute force.


The Mistakes (a.k.a. The Educational Section)

Mistake 1: I Reused the IV

Early version. I generated one IV when the session was created and reused it for every message.

This is catastrophically wrong with AES-GCM.

Reusing an IV with the same key doesn't just "weaken" the encryption. It breaks it completely. An attacker who collects two messages encrypted with the same key and same IV can XOR the ciphertexts and recover the XOR of the plaintexts β€” which combined with any knowledge of one message's content, reveals the other. The 96-bit IV space with fresh random IVs per message means you'd need to send ~4 billion messages before a collision becomes likely. Fine.

Fresh IV every single time. No exceptions.

Mistake 2: I Forgot About Late Joiners

Alice creates the session. Bob joins. They exchange public keys. The session key is distributed.

Then Carol joins.

Carol never received Bob's public key broadcast. Bob's not going to re-send it β€” he already sent it. Carol can't decrypt anything Bob sent before she joined.

The fix: store every participant's public key on the server-side participant object. When anyone joins, the server includes every existing participant's public key in the joined response. Carol gets everyone's keys in one message the moment she connects, no matter when she joins.

@dataclass
class _Participant:
    ws: WebSocket
    ws_id: str
    name: str
    is_creator: bool = False
    public_key: Optional[str] = None  # ← stored here, sent to late joiners
Enter fullscreen mode Exit fullscreen mode

Mistake 3: I Decremented the Timer Instead of Using the Wall Clock

First implementation:

# WRONG β€” don't do this
remaining = ttl_seconds
while remaining > 0:
    await asyncio.sleep(1)
    remaining -= 1  # ← this drifts
Enter fullscreen mode Exit fullscreen mode

Under any real server load, asyncio.sleep(1) sleeps for 1.003 seconds, or 1.01 seconds, or however long the event loop was busy. Multiply that over 600 ticks for a 10-minute session and your timer is off by 6–10 seconds.

The fix is what I showed earlier β€” always compute remaining = expires_at - datetime.now(). The wall clock doesn't drift.


What Actually Happens When It Burns

Let's trace the exact moment a session ends:

  1. _countdown_loop wakes up, computes remaining ≀ 0
  2. Calls _destroy_session(token)
  3. Server broadcasts {"type": "destroyed"} to every WebSocket simultaneously
  4. Every browser receives the message in the same event loop iteration
  5. Every client's React state changes to phase = 'destroyed'
  6. Every screen shows the flame animation at the same moment
  7. Server closes all WebSocket connections
  8. _SESSIONS.pop(token) β€” Python garbage collects the dict entry

From step 3 to step 8, the real-world time is milliseconds. There's no race where one person sees the burn and another doesn't for a few seconds.


The Honest Security Section

I'm not going to pretend this is Signal. Here's what it actually does:

Protects you from:

  • βœ… Someone hacking my server and reading messages (server only has ciphertext)
  • βœ… Passive network interception (TLS + E2E encryption)
  • βœ… Accidentally leaving a chat history lying around
  • βœ… The "I sent that to the wrong person" anxiety (it's gone in 5 minutes anyway)

Does NOT protect you from:

  • ❌ The other person screenshotting your messages (classic)
  • ❌ Someone compromising your browser specifically
  • ❌ A sufficiently motivated adversary with physical access to a device
  • ❌ The session fingerprint requires you to actually compare codes out loud β€” if you skip that step, technically MITM is possible

Use this for: private conversations you genuinely want to disappear.

Don't use this for: evading law enforcement, anything actually illegal, evidence tampering.


If I Had More Time (And More Energy)

Things I'd add if I weren't one person doing this in my spare time:

Redis + sticky sessions β€” right now the in-memory dict means one server instance. Horizontal scaling would need Redis (but with careful thought about what goes in Redis vs. stays ephemeral).

A proper ratchet β€” the current design uses one session key for all messages. The Double Ratchet algorithm (what Signal uses) gives each message its own key derived from the previous one β€” compromise of one message doesn't reveal any others. Cool. Complex. Maybe v2.

Forward secrecy at the server layer β€” currently if somehow the session key leaked, all past messages in that session could be decrypted. A proper ratchet would fix this.

The fingerprint experience β€” right now it's a code you can optionally compare. It should be more prominent, maybe with a visual comparison like Signal's Safety Numbers.


Try It

Two tabs. Open them both.

  1. Go to bar-rnr.vercel.app/burn-chat
  2. Create a session with a 5-minute timer
  3. Copy the link, open it in the second tab
  4. Send some messages between the tabs
  5. Wait for the timer, or just wait for the fire

Everything disappears. Both tabs. At the same time.

It's a small thing but it never gets old.


Source code: github.com/Mrtracker-new/BAR_RYY

Built by one person, alone, late at night, because spy movies are inspiring and sleep is overrated. If you find a bug, open an issue. If you find a security hole, please tell me before you do anything fun with it. πŸ”₯

Top comments (0)