Remember buddy lists? Away messages you rewrote ten times? The nudge that shook your friend's entire window?
I missed all of it, so I built Y2K Chat — a retro, 2000s-style messenger for your real friends only. Android app + iPhone (no App Store), running on a stack that costs $0/month at small scale.
Here's how it works under the hood, and the bugs that bit me.
The rules I set before writing code
- No strangers. No search, no public profiles, no discovery.
- No AI, no ads, no feeds, no trackers.
- Text, nudges and voice only — no photos at launch.
- Everything deletes itself after 30 days.
- $0 running cost. No service that needs a credit card.
The stack
- Supabase (free tier): Postgres + Row Level Security, Auth, Realtime (presence + broadcast), Storage, Edge Functions, pg_cron
- Android: Kotlin + Jetpack Compose
- iPhone: a PWA (Vite + Preact) on Cloudflare Pages — installed via Safari → Add to Home Screen
- Push: FCM for Android, standard Web Push (VAPID) for iPhone, both sent from an Edge Function
Friendship is enforced in the database, not the UI
Every user gets a friend code. Adding someone does nothing until they add you back — and that rule lives in Postgres functions + RLS, not in the client. A one-sided add is indistinguishable from a decline or a block, so you can't probe whether someone blocked you. Circles (group chats, max 10) only admit a new member when every existing member approves.
The database has a few hundred SQL tests covering friendship, circles, blocks, reports and retention. If the client gets something wrong, the database still says no.
The presence bug that "sometimes worked"
Online/away/offline is the heart of the app, and for a while it worked… sometimes.
The cause: the realtime server only sends presence events on a channel if the client declares, at join time, that it's listening for presence. The Android client library only declared it if a presence listener already existed when the join went out — and I was registering the listener on a background coroutine. Whenever the join won the race, that channel got zero presence events, forever.
Fix: register the listener before joining. Obvious in hindsight, invisible in testing because it only failed when timing went the wrong way.
Push without leaking messages
Pushes are data-only. The notification just says "new message"; the app fetches the actual text itself from the backend. Message content never passes through Google's or Apple's push servers, and edge-function logs only ever contain IDs and counts.
iPhone without the App Store
The iPhone version is a PWA. What works: chat, buddy list, nudges (screen shake + sound — iOS web can't vibrate), voice tapes, push (iOS 16.4+, only after Add to Home Screen). What doesn't: background presence — iOS freezes web apps the moment they leave the screen. The onboarding pushes Add to Home Screen hard, because a messenger without notifications is dead.
"Online when you're actually free"
On Android (opt-in), presence is computed on-device: unlocked recently → online, Do Not Disturb → away, optionally a busy calendar event → away. Only the resulting status is sent — never the raw signals.
The answering machine 📼
When nobody's online, tapping the mic plays a greeting, then a beep, then records up to 60 seconds. Playback has a tape counter, hiss and a rewind whirr. Format is AAC everywhere — Opus plays unreliably in iPhone browsers.
The bug here: a tape notification arrived, but the tape wasn't in the chat when you opened it minutes later. The upload was fine; the receiving side wasn't reloading the conversation when opened from a notification. Lesson: anything opened from a push should load fresh from the server, and signed media URLs should be generated at play time, not fetch time.
Try it
- 📱 Android: https://play.google.com/store/apps/details?id=com.arapps.y2kchat
- 🍎 iPhone: open https://y2kchat.pages.dev in Safari → Share → Add to Home Screen
It's built for small circles — it's most fun when your old chat buddies are on it too.
Questions about the stack, the presence race, or Web Push on iOS — ask away in the comments. 👇
Top comments (3)
Enforcing friendship in Postgres functions and RLS instead of the UI, with a few hundred SQL tests behind it, is a really solid way to build a messenger. One question on the voice tapes: are they in a private Storage bucket with per-user folder rules? A public bucket serves a file to anyone who has its link.
Thanks! Yes, both buckets (tapes and greetings) are private. Access is per conversation membership, not per user folder: files sit under tapes//…, you can only upload to a conversation you’re allowed to post in, and you can only play a tape you can actually see in a conversation you can read. That means not unsent, and not from before you joined. The uploader can see their own file only until the tape row exists. Playback uses signed URLs generated at play time that expire after 10 minutes, and expired tapes are cleaned up through the Storage API by an hourly job.
love that playback is tied to what you can actually see in the conversation, really thoughtful setup. thanks for the detailed answer