Skip to content
All work

Real-time

Discord Clone

Servers, channels, real-time chat, and live video rooms.

Year
2024
Role
Sole engineer
Type
Real-time

What it is

A Discord-shaped application: users create servers, servers hold channels, channels hold messages, and members have roles that decide what they can do. On top of that sits live audio and video, so a text channel can become a call without leaving the page. I built it to find out what actually breaks when one app has to run three different transports at once.

By the numbers

3
Concurrent transports
5
External services wired
What happens when someone sends a message
  1. 01Composeclient
  2. 02POSTroute handler
  3. 03Authoriserole check
  4. 04PersistPrisma
  5. 05EmitSocket.IO
  6. 06Fan outchannel room

The problem worth solving

Three transports, three different sets of guarantees. HTTP is request-response and reliable, and it is how history loads. WebSockets are persistent, ordered but droppable, and they are how new messages arrive. WebRTC is peer-to-peer, lossy by design, and it is how audio and video work.

The difficulty is that the user does not care about any of that. They expect one coherent view of a conversation. So the real work is reconciling three sources into a single piece of state that never shows a message twice, never loses one, and never disagrees with what the server thinks happened.

What I built

Messages are written over HTTP and broadcast over WebSocket — never written over the socket directly. The route handler authorises against the member's role, persists through Prisma, and only then emits to the channel room. That ordering means the database is always the source of truth and the socket is only a notification that something changed.

History loads through TanStack Query with cursor pagination, so scrolling up fetches the next window rather than re-fetching a growing offset. Incoming socket messages are merged into the same cache, which is what keeps live messages and loaded history in one list instead of two.

Audio and video run on LiveKit rather than raw WebRTC. Clerk handles authentication, and UploadThing handles attachments and avatars.

What I would change

The Socket.IO server runs alongside Next, which is the weakest part of the design. It works, but it ties the realtime layer to the lifecycle of the web app and does not survive being deployed to a serverless platform.

If I rebuilt it, the socket layer would be a separate long-lived service and the Next app would be a client of it. That is the difference between a project that demonstrates the pattern and one that could actually take traffic.

Decisions

Four calls I would make the same way again

  • 01

    Persist before broadcasting

    A message is written to PostgreSQL and then emitted. Emitting first would feel marginally faster and would occasionally show people a message that was never saved — which is worse than any latency.

  • 02

    Cursor pagination, not offset

    Offsets shift when new rows arrive, so a busy channel would duplicate or skip messages as you scrolled. A cursor is stable against inserts, which is exactly the property a live feed needs.

  • 03

    LiveKit instead of raw WebRTC

    Signalling, TURN servers and renegotiation are a project of their own, and getting them subtly wrong produces calls that work on your machine and fail on everyone else's. LiveKit is the part I chose not to reinvent.

  • 04

    Roles checked on the server

    The client hides controls a member cannot use, but the route handler is what actually enforces it. Hiding a button is presentation; refusing the request is authorisation.

Stack

Realtime

  • Socket.IO
  • LiveKit
  • TanStack Query

Data

  • PostgreSQL
  • Prisma
  • Zod

Platform

  • Next.js 14
  • Clerk
  • UploadThing
  • Zustand

Next case study

Al-Birr