Skip to content
Asikur Rahman
← All work

Built end to end — solo

Linky

A real-time chat app — one-to-one, groups and channels with presence, typing indicators, read receipts, reactions and file sharing. An Express + Socket.io backend behind the Redis adapter, a Next.js client, and MongoDB via Prisma.

Open sourceView source

01The problem

The easy 90% of a chat app is delivering a message; the last 10% is presence — showing who is actually online — and presence is where naive implementations quietly lie. You flip a user ONLINE when their socket connects and OFFLINE when it disconnects, but disconnects are exactly the events you can't count on: a crashed tab, a dropped connection or a killed process never fires one, so the 'online' list slowly fills with ghosts.

02The constraint

Presence has to be live (read from Redis on every render) yet also persisted (a status column other queries can join on), and the two can't be allowed to drift. A user with several tabs open must count as one online user, and the whole socket layer has to scale across nodes behind the Redis adapter.

03What I built

On connect, the socket joins the user's own room plus every chat and group room — membership is read Redis-first and only falls back to MongoDB on a cache miss, then backfills Redis, so a reconnect rejoins everything without a database hit.

Presence is a Redis set of online user ids, written on connect and cleared on disconnect and kept warm by a client heartbeat; reads are O(1) against Redis, and a MongoDB status field mirrors it for anything that queries users by state.

Because disconnect events get missed, a background reconciler (syncOfflineUsers) walks the persisted ONLINE users in cursor-paginated batches, diffs each batch against the live Redis set, and flips the ghosts to OFFLINE — so the durable status heals itself instead of drifting forever.

Messaging carries read receipts (a MessageSeen record per viewer), reactions and media; files upload to Cloudinary and attach to the message.

Delivery, presence and typing all fan out over Socket.io behind the Redis adapter so rooms work across nodes; typing indicators go to the chat room but skip the sender.

SOCKETSCONNECT · HBREDIS SETLIVE TRUTHDB STATUSMIRRORRECONCILERCURSOR BATCHESHEAL GHOSTSCONNECT / HEARTBEATDB ONLINE − REDIS SET = GHOSTS → OFFLINE
Architecture — Linky

04The decision

Options considered

Presence has two sources of truth fighting each other: the live Redis set (fast, but only as correct as the events that write it) and the persisted status field (durable, but a lagging copy). The question is how to keep them honest when disconnects go missing.

  • ATrust socket disconnect events — flip OFFLINE on disconnect. Simplest, but a crash or dropped connection never fires one, so ghosts accumulate.
  • BHeartbeat-only with a TTL — expire presence if no heartbeat arrives. Self-healing, but one missed beat on a flaky network flickers a real user offline.
  • CRedis set as the live truth plus a heartbeat, and a background reconciler that periodically corrects the durable copy against Redis.

Chose Redis-live presence with a background reconciler.

The trade-off I accepted

Trusting disconnects is clean until you realise half of them never fire; a pure TTL heartbeat heals itself but makes presence twitchy on bad networks — one dropped beat and a user who's really there blinks offline. I kept Redis as the live source read on every render and a heartbeat to keep it warm, and accepted that the durable copy will lag: a background job sweeps the persisted ONLINE users in cursor-paginated batches and reconciles them against the Redis set. The cost is that 'offline' isn't instant — a ghost can linger until the next sweep — but in exchange presence is cheap to read, survives the disconnects that never fire, and never wedges a live user offline over a single lost packet.

05The result

  • Presence reads are O(1) against a Redis online-set; the durable status is reconciled in cursor-paginated batches, so it self-heals from missed disconnects instead of drifting.
  • Room membership is Redis-first with a MongoDB fallback, so a reconnect rejoins every chat and group without a database hit.
  • Rooms, presence and typing work across nodes behind the Socket.io Redis adapter; read receipts, reactions and Cloudinary media round out the messaging.

06Stack

  • Express
  • Socket.io
  • Redis adapter
  • Prisma
  • MongoDB
  • Cloudinary
  • JWT
  • Next.js
  • TypeScript