DEV Community

Ab
Ab

Posted on Edited on

I rebuilt 2000s instant messaging, nudges included, on a $0 stack

 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

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)