When I set out to build Relay, I had one clear goal: I wanted to truly understand what happens under the hood when you hit "send" on an app like WhatsApp or Telegram. No Firebase, no Socket.IO, no magic. Just raw WebSockets, Node.js, and a PostgreSQL database.
In my previous project updates, I outlined the high-level architecture. Today, I want to go deeper. This is a deep dive into the 5-step message send flow—expanding on the sequence of events, the ACK pattern, and how the system gracefully handles offline users.
If you're curious about the mechanics of real-time distributed systems, grab a coffee. Let's dissect how a message actually gets from User A to User B.
The Architecture at a Glance
Before we dive into the code, let's visualize the flow. Here is the exact sequence of events when a user sends a message:
(Note: In a local single-instance setup, Server A and Server B are the same server, but relying on Redis Pub/Sub means this scales horizontally out of the box).
P.S. Sorry about the blurry architecture diagram—I couldn't get it to export at a higher resolution. I'll replace it with a better version soon.
Step 1: The Frontend Send (JSON Frames)
With raw WebSockets, you don't get the luxury of Socket.IO's fancy event emitters. Everything sent over the wire is just a string. In Relay, I use JSON frames with a discriminated union structure to strictly type all messages.
When you hit "Send" in the React frontend, it triggers the sendMessage function inside my custom useWebSocket hook:
const sendMessage = useCallback((to: string | undefined, content: string, id: string, groupId?: string) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
if (groupId) {
// Group message logic...
} else if (to) {
socketRef.current?.send(
JSON.stringify({
type: "send_message",
payload: { to, content, id },
}),
);
}
}
}, []);
It’s completely asynchronous from the frontend's perspective. We just fire the JSON frame into the void and trust the WebSocket connection to carry it.
Step 2: The Backend Handler & Database Persistence
When the backend receives this frame, the send_message event is routed to my message handler. The handler does two critical things simultaneously: it persists the message to PostgreSQL and publishes it to Redis.
export async function sendMessage(
senderId: string,
msg: Extract<WsMessage, { type: "send_message" }>,
) {
const { to, content } = msg.payload;
// 1. Check if user is online to determine delivery status
const deliveredAt = (await presence.isOnline(to)) ? new Date() : null;
// 2. Persist to Postgres via Prisma
await saveMessage(to, senderId, content, msg.payload.id, deliveredAt);
const sender = await prisma.user.findUnique({ where: { id: senderId } });
// 3. Publish to Redis Pub/Sub for cross-instance delivery
await pubsub.publishMessage({
userId: to,
payload: {
type: "receive_message",
payload: {
from: senderId,
name: sender?.name,
content,
id: msg.payload.id,
},
},
});
// (Delivery acknowledgment logic for the sender omitted for brevity)
}
Notice the presence.isOnline(to) check. I am definitively recording whether this message is undelivered or delivered right at the moment of creation. If the user is offline, deliveredAt is set to null.
Step 3: The Redis Pub/Sub Delivery
Why use Redis here instead of just looking up the recipient in an in-memory Map of active WebSockets?
If you rely solely on a local Map<userId, WebSocket>, your chat app can never scale beyond a single Node.js process. If User A connects to Server 1 and User B connects to Server 2, Server 1 has no way to natively push a message to User B's socket.
By publishing the receive_message payload to a Redis channel, every backend instance hears it. The instance that actually holds User B's live WebSocket connection intercepts the Redis event and pushes it down the wire:
{
"type": "receive_message",
"payload": {
"from": "user-a-uuid",
"content": "Hey, what's up?",
"id": "msg-1234"
}
}
Step 4: The ACK Pattern
Here’s a subtle but crucial detail of building raw WebSocket apps: How does the sender know the server actually received and processed the message?
HTTP has status codes. You make a POST request, you get a 201 Created back. WebSockets, however, are full-duplex streams. When the client calls socket.send(), there is no automatic confirmation.
To solve this, I implemented an ACK (acknowledgment) pattern in the main socket listener.
ws.on("message", async (data) => {
try {
const msg = WsMessageSchema.parse(JSON.parse(data.toString()));
switch (msg.type) {
case "send_message": {
await sendMessage(id, msg); // DB save and Redis publish happens here
// Return the ACK to the sender
ws.send(
JSON.stringify({ type: "ack", payload: { status: "sent" } }),
);
break;
}
// ... other cases
}
} catch (e) {
ws.send(JSON.stringify({ type: "error", payload: { message: "Failed to process message" } }));
}
});
When the frontend receives this { type: "ack" }, it knows the message safely made it to the database. Before this ACK, the message should technically show a "sending..." state (like a clock icon) in the UI. Once the ACK hits, we transition to a single checkmark (Sent).
Step 5: What Happens When the Recipient is Offline?
If the recipient is completely offline (their phone is off, tab is closed), the Redis publish happens, but no server instance picks it up to deliver via WebSocket.
Because presence.isOnline(to) returned false back in Step 2, the database row has deliveredAt: null.
When the recipient finally opens the app, two things happen:
-
REST History Fetch: The frontend fetches the chat history via a standard HTTP REST endpoint (
/messages). I use HTTP for history because it's stateless, easily cacheable, and better suited for loading large JSON arrays than WebSocket frames. - Delivery Confirmation: During the WebSocket connection handshake for the newly online user, the backend runs a cleanup task:
// Inside the WebSocket connection handler
sockets.addUser(id, ws);
// Find all messages sent to this user where deliveredAt is null
// Update them with the current timestamp and notify senders
await markMessagesAsDelivered(id);
markMessagesAsDelivered queries the database for all pending messages, updates them, and then fires off delivered_messages events through Redis back to the original senders. The sender's UI instantly flips from a single checkmark to a double checkmark, knowing their message has finally landed on the recipient's device.
Wrapping Up
As I said in the previous post in the series, abstracting WebSockets with tools like Socket.IO is great for getting an MVP out the door quickly. But building the message framing, persistence logic, ACK patterns, and Redis pub/sub layer yourself gives you an unparalleled understanding of system design.
You stop seeing a chat app as "magic" and start seeing it as a beautifully orchestrated dance of database rows, pub/sub channels, and TCP streams.
If you're building something similar or want to dive into the codebase, Relay is completely open-source. Feel free to reach out in the comments!
I'll be back with more next week. Until then, stay consistent!

Top comments (0)