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 (0)