When dealing with WebSockets, most tutorials will straightaway hand you Socket.io and call it a day. You get a working chat app in short period of time but you have no idea what happened.
I actually wanted to understand what was going on, how WebSockets work, how auth happens, how to deal with cookies when working with WebSockets and so many more tiny things.
So I built Relay. A full stack real-time chat app with raw WebSockets, Express for the backend, React for the frontend and PostgreSQL.
The reason for doing everything manually was that I wanted to learn. I did not want to launch and make a chat app and sell it. I just wanted to learn the implementation of these technologies.
Thus, here we are. This is the first post in a series where I will be exploring the project and breaking down the most interesting technical problems I ran into. But first, let's see what Relay is, how it works, and why I built it the way I did.
What Relay Does
- Real-time messaging — Messages are sent and received instantly over WebSockets. No polling, no long-polling, no hacks.
- Persistent history — Every message is stored in PostgreSQL via Prisma ORM. Close the tab, logout, the messages will still be there when you come back.
- Cookie-based auth — JWT tokens stored in httpOnly cookies. The WebSocket handshake authenticates by manually parsing cookies from the upgrade request headers.
- Dual-protocol architecture — REST API(the express part) handles the CRUD stuff (auth, fetching users, loading message history). WebSockets handle the real-time stuff (sending messages, live delivery). Each protocol does what it's good at and that is exactly what I wanted to learn to do myself. And also the fact that how would I implement both these protocols in the same project for maximum efficiency.
Architecture and Flow
The app follows a fairly standard real-time chat architecture with a clear separation between frontend and backend responsibilities.
On the frontend, the application is built using React, TypeScript, and Vite. Authentication state is managed through an AuthContext, chat history is handled using a useMessages hook, and real-time communication is managed by a useWebSocket hook. The frontend communicates with the backend in two ways:
REST API calls using Axios over HTTP for things like authentication, fetching users, loading messages, and logout.
WebSocket connections (ws://) for real-time messaging and live updates.
On the backend, the application uses Node.js with Express for REST APIs and the ws library for WebSocket support. The backend exposes REST routes such as /signup, /signin, /users, /messages, /me, and /logout. Alongside this, a WebSocket server handles persistent connections, live message delivery, and connection management.
Both the REST API layer and WebSocket server interact with the database through Prisma ORM, which connects to a PostgreSQL database for storing users, messages, authentication data, and related application state.
Now for the flow. This is the part I find most interesting. Here's what happens when you hit send:
-
Frontend calls
sendMessage(to, content)which writes a JSON frame to the open WebSocket:{ type: "send_message", payload: { to, content } } -
Backend WS handler receives it, validates the payload, and does two things simultaneously:
- Persists the message to PostgreSQL via Prisma
- Looks up the recipient's socket in the in-memory connection store (which I will update to be a singleton SocketManager class instead of just a map)
- If the recipient is online, the backend pushes the message to their socket as a
receive_messageevent, they see it instantly - If they're offline, the message is still in the database. Next time they open the chat, the frontend fetches history via the REST endpoint
/messages?userId=... - The sender gets an
ackback over the WebSocket confirming the message was processed
No message broker, no queue, no Redis (yet, as that is the plan for v2). Just an in-memory Map<userId, WebSocket> and a database. It's simple, it works for a single server, and it's the kind of thing that makes you appreciate what a system like Kafka(v2.5 maybe?) is actually solving when you eventually need it.
Here are the interesting bits
All of these will be individual posts in the coming days.
1. Cookie auth over WebSockets
Auth over REST is easy, but how does it happen when dealing with persistent connections. WebSockets don't have a middleware chain and also have no authorization header during the upgrade handshake.
The solution - cookies!.
2. The React Stale Closure bug
Imagine opening a chat in two tabs, you receive a message from User A, but see it silently render in the chat window for User B. No backend errors, just a complete UI mismatch.
3. The dual protocol magic
How the HTTP and WS protocols interleave.
4. Raw WS
How you create every feature you need using raw WebSockets instead of Socket.io and have so much more understanding of the concepts.
Wrapping up
For those of you who would like to go through the codebase, you can do so here.
I'll be back with more next week. Until then, stay consistent!
Top comments (0)