Build a WhatsApp-Style Messaging System
Build a real-time messaging backend with WebSockets, routing, and delivery receipts.
Introduction
30 Second Summary
Every time you send a WhatsApp message, it reaches your friend in milliseconds. Behind that simplicity is a complex backend handling millions of simultaneous connections, routing messages to the right device, and making sure nothing gets lost when someone's phone is off.
In this project, you will build a real-time messaging backend that solves the same problems WhatsApp solves: tracking who's connected, routing messages to the right person, detecting when users go offline, and queuing messages until they come back.
What You'll Build
A WebSocket-based messaging server using Node.js, Express, and Socket.IO that implements connection management, message routing, presence detection, delivery receipts, and offline message queuing.
By the end of this project, you'll have:
- A connection registry that maps users to their active sockets, mirroring how WhatsApp routes messages across its server cluster.
- A presence system that detects when users come online or go offline and broadcasts status changes in real time.
- Three-stage delivery receipts (sent, delivered, read) and offline message queuing that match WhatsApp's exact patterns.
- Secret Mission: Add group messaging with fan-out to experience how complexity scales with multiple recipients.
Are there any prerequisites?
You should have basic JavaScript knowledge (variables, functions, objects, arrays) and be comfortable running commands in a terminal. No prior experience with WebSockets or real-time systems is needed.
Set Up Your Development Environment
Before you can build the messaging backend, you need a working environment with Node.js (the runtime that executes your server code) and a code editor.
In this step, you'll install Node.js, create a project folder, install two key dependencies, and get a pre-built chat client running in the browser. The client gives you a visual way to test your backend as you build it in the next steps.
In this step, get ready to:
- Install Node.js and VS Code.
- Create the project folder and install Express and Socket.IO.
- Run a minimal server with the pre-built client to verify the WebSocket connection.
Install Node.js and verify
Node.js is a JavaScript runtime that lets you run JavaScript outside the browser. Your messaging server will run on Node.js using two libraries: Express (for serving files over HTTP) and Socket.IO (for real-time WebSocket communication).
- Download and install Node.js LTS from https://nodejs.org.
- Run the installer and accept the default settings.
Once installed, verify Node.js is working by opening a terminal.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Node.js version by running this command:
node --version
- Also check that npm (Node's package manager) is available by running:
npm --version
✔️ I see a version number
Node.js is installed and ready. Move on to the next substep.
ⓧ Command not found
Node.js is not installed yet. Follow these steps:
- Go to https://nodejs.org and click the LTS download button.
- Run the downloaded installer and accept all defaults.
- Close and reopen your terminal, then run node --version again.
You also need a code editor. If you do not have one installed yet, download VS Code from https://code.visualstudio.com.
Create the project and install dependencies
Every Node.js project lives in its own folder with a package.json file that tracks metadata and dependencies.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir whatsapp-system
cd whatsapp-system
- Initialize the project by running:
npm init -y
What does this command do?
The -y flag tells npm to accept all defaults and generate a package.json immediately. This file tracks your project's name, version, and installed packages.
- Install Express and Socket.IO by running:
npm install express@4 socket.io@4
What does this command do?
This installs two packages. express@4 is a web framework that serves your frontend files over HTTP. socket.io@4 adds real-time WebSocket support on top of Express.
The @4 pins both packages to major version 4, ensuring compatibility with the code in this project.
- Confirm the packages installed by running:
ls node_modules
You should see a long list of folders. Both express and socket.io should appear among them.
Seeing permission errors?
On macOS, if you see an EACCES error, try prefixing the install command with sudo. On Windows, make sure you are running PowerShell as an administrator.
help me fix npm install permission errors
- Create the public/ folder for the frontend files by running:
mkdir public
- Open the whatsapp-system folder in VS Code by running:
code .
code command not found?
If the code command does not work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then click File > Open Folder and select the whatsapp-system folder on your Desktop.
Add the pre-built client and verify the connection
A pre-built browser client is provided so you can focus entirely on the server-side architecture. You will paste three files into the public/ folder: the HTML structure, CSS styling, and JavaScript that handles the UI.
- In the VS Code Explorer sidebar, right-click the public folder and select New File.
- Name the file index.html and paste the following code into it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WhatsApp System Design</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- Login Screen -->
<div id="login-screen">
<h1>WhatsApp System Design</h1>
<p>Enter a username to connect</p>
<input type="text" id="username-input" placeholder="Your username" />
<button id="login-btn">Sign In</button>
</div>
<!-- Chat Screen -->
<div id="chat-screen" class="hidden">
<div id="sidebar">
<h2 id="current-user"></h2>
<h3>Online Users</h3>
<ul id="user-list"></ul>
</div>
<div id="chat-panel">
<div id="chat-header">Select a user to start chatting</div>
<div id="messages"></div>
<div id="input-area" class="hidden">
<input type="text" id="message-input" placeholder="Type a message..." />
<button id="send-btn">Send</button>
</div>
</div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="app.js"></script>
</body>
</html>
- Create another file in the public folder called style.css and paste the following code:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #111b21;
color: #e9edef;
height: 100vh;
}
#app {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#login-screen {
text-align: center;
}
#login-screen h1 {
color: #25d366;
margin-bottom: 10px;
}
#login-screen p {
color: #8696a0;
margin-bottom: 20px;
}
#login-screen input {
padding: 10px 15px;
border: none;
border-radius: 8px;
background: #2a3942;
color: #e9edef;
font-size: 16px;
width: 250px;
margin-right: 10px;
}
#login-screen button {
padding: 10px 20px;
border: none;
border-radius: 8px;
background: #25d366;
color: #111b21;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
#chat-screen {
display: flex;
width: 100%;
height: 100vh;
}
#sidebar {
width: 300px;
background: #111b21;
border-right: 1px solid #2a3942;
padding: 20px;
overflow-y: auto;
}
#sidebar h2 {
color: #25d366;
margin-bottom: 20px;
font-size: 14px;
}
#sidebar h3 {
color: #8696a0;
font-size: 12px;
text-transform: uppercase;
margin-bottom: 10px;
}
#user-list {
list-style: none;
}
#user-list li {
padding: 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 4px;
}
#user-list li:hover {
background: #2a3942;
}
#user-list li.active {
background: #2a3942;
}
#user-list li::before {
content: '';
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #25d366;
margin-right: 10px;
}
#chat-panel {
flex: 1;
display: flex;
flex-direction: column;
background: #0b141a;
}
#chat-header {
padding: 15px 20px;
background: #202c33;
border-bottom: 1px solid #2a3942;
font-weight: bold;
}
#messages {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.message {
max-width: 65%;
padding: 8px 12px;
border-radius: 8px;
margin-bottom: 8px;
position: relative;
}
.message.sent {
background: #005c4b;
margin-left: auto;
}
.message.received {
background: #202c33;
margin-right: auto;
}
.message .content {
margin-bottom: 4px;
}
.message .meta {
font-size: 11px;
color: #8696a0;
text-align: right;
}
.message .receipt {
margin-left: 5px;
}
.receipt-sent {
color: #8696a0;
}
.receipt-delivered {
color: #8696a0;
}
.receipt-read {
color: #53bdeb;
}
#input-area {
display: flex;
padding: 10px 20px;
background: #202c33;
border-top: 1px solid #2a3942;
}
#input-area input {
flex: 1;
padding: 10px 15px;
border: none;
border-radius: 8px;
background: #2a3942;
color: #e9edef;
font-size: 14px;
}
#input-area button {
margin-left: 10px;
padding: 10px 20px;
border: none;
border-radius: 8px;
background: #25d366;
color: #111b21;
font-weight: bold;
cursor: pointer;
}
.hidden {
display: none !important;
}
- Create one more file in the public folder called app.js and paste the following code:
const socket = io();
// State
let currentUser = '';
let activeChat = '';
const messages = {};
// DOM elements
const loginScreen = document.getElementById('login-screen');
const chatScreen = document.getElementById('chat-screen');
const usernameInput = document.getElementById('username-input');
const loginBtn = document.getElementById('login-btn');
const currentUserDisplay = document.getElementById('current-user');
const userList = document.getElementById('user-list');
const chatHeader = document.getElementById('chat-header');
const messagesDiv = document.getElementById('messages');
const inputArea = document.getElementById('input-area');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
function formatTime(ts) {
const date = new Date(ts);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function getReceiptHtml(receipt) {
switch (receipt) {
case 'sent':
return '<span class="receipt receipt-sent">✓</span>';
case 'delivered':
return '<span class="receipt receipt-delivered">✓✓</span>';
case 'read':
return '<span class="receipt receipt-read">✓✓</span>';
default:
return '';
}
}
function renderMessages() {
if (!activeChat) return;
const chatMessages = messages[activeChat] || [];
messagesDiv.innerHTML = chatMessages.map((msg) => {
const isSent = msg.from === currentUser;
const receiptHtml = isSent ? getReceiptHtml(msg.receipt) : '';
return `
<div class="message ${isSent ? 'sent' : 'received'}" data-id="${msg.id}">
<div class="content">${msg.content}</div>
<div class="meta">${formatTime(msg.timestamp)}${receiptHtml}</div>
</div>
`;
}).join('');
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function renderUserList(users) {
userList.innerHTML = users
.filter((u) => u !== currentUser)
.map((u) => `<li data-user="${u}" class="${u === activeChat ? 'active' : ''}">${u}</li>`)
.join('');
userList.querySelectorAll('li').forEach((li) => {
li.addEventListener('click', () => {
activeChat = li.dataset.user;
chatHeader.textContent = activeChat;
inputArea.classList.remove('hidden');
renderUserList(Array.from(document.querySelectorAll('#user-list li')).map(l => l.dataset.user).concat([activeChat]));
renderMessages();
const chatMessages = messages[activeChat] || [];
chatMessages.forEach((msg) => {
if (msg.from !== currentUser && msg.receipt !== 'read') {
socket.emit('message-read', { messageId: msg.id, to: msg.from });
}
});
});
});
}
loginBtn.addEventListener('click', () => {
const username = usernameInput.value.trim();
if (!username) return;
currentUser = username;
socket.emit('register', username);
loginScreen.classList.add('hidden');
chatScreen.classList.remove('hidden');
currentUserDisplay.textContent = `Signed in as: ${username}`;
});
usernameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') loginBtn.click();
});
function sendMessage() {
const content = messageInput.value.trim();
if (!content || !activeChat) return;
const messageId = generateId();
const msg = {
id: messageId,
from: currentUser,
content,
timestamp: Date.now(),
receipt: 'pending'
};
if (!messages[activeChat]) messages[activeChat] = [];
messages[activeChat].push(msg);
renderMessages();
socket.emit('send-message', { to: activeChat, content, messageId });
messageInput.value = '';
}
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
socket.on('online-users', (users) => { renderUserList(users); });
socket.on('user-online', (username) => {
const existingUsers = Array.from(document.querySelectorAll('#user-list li')).map(l => l.dataset.user);
if (!existingUsers.includes(username)) existingUsers.push(username);
renderUserList(existingUsers);
});
socket.on('user-offline', (username) => {
const existingUsers = Array.from(document.querySelectorAll('#user-list li')).map(l => l.dataset.user).filter(u => u !== username);
renderUserList(existingUsers);
});
socket.on('message-sent', ({ messageId }) => {
for (const user in messages) {
const msg = messages[user].find(m => m.id === messageId);
if (msg) { msg.receipt = 'sent'; break; }
}
renderMessages();
});
socket.on('message-delivered', ({ messageId }) => {
for (const user in messages) {
const msg = messages[user].find(m => m.id === messageId);
if (msg) { msg.receipt = 'delivered'; break; }
}
renderMessages();
});
socket.on('message-read', ({ messageId }) => {
for (const user in messages) {
const msg = messages[user].find(m => m.id === messageId);
if (msg) { msg.receipt = 'read'; break; }
}
renderMessages();
});
socket.on('receive-message', (msg) => {
const chatWith = msg.from;
if (!messages[chatWith]) messages[chatWith] = [];
messages[chatWith].push({
id: msg.messageId,
from: msg.from,
content: msg.content,
timestamp: msg.timestamp,
receipt: 'delivered'
});
socket.emit('message-delivered', { messageId: msg.messageId, to: msg.from });
if (activeChat === msg.from) {
socket.emit('message-read', { messageId: msg.messageId, to: msg.from });
}
const existingUsers = Array.from(document.querySelectorAll('#user-list li')).map(l => l.dataset.user);
if (!existingUsers.includes(chatWith)) {
existingUsers.push(chatWith);
renderUserList(existingUsers);
}
renderMessages();
});
What do these client files do?
The three files you just pasted handle all the frontend work for this project. index.html provides the page structure (login form, sidebar, chat panel). style.css styles everything to look like WhatsApp's dark theme.
The app.js file connects to your server via Socket.IO and handles sending messages, rendering receipts, and updating the user list. You will not need to edit these files. Your job is to build the server that powers them.
Now you need the server that ties everything together.
- In the VS Code Explorer sidebar, right-click the whatsapp-system folder (the top-level folder, not public) and select New File.
- Name the file server.js and paste the following code:
// Import dependencies
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
// Create Express app and wrap it in an HTTP server
const app = express();
const server = http.createServer(app);
// Attach Socket.IO to the HTTP server
const io = new Server(server);
// Serve frontend files from the public/ folder
app.use(express.static('public'));
// Handle new WebSocket connections
io.on('connection', (socket) => {
console.log('Socket connected:', socket.id);
});
// Start the server on port 3000
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
This sets up the minimum viable server. Express serves your static frontend files (HTML, CSS, JS) when a browser visits http://localhost:3000.
Socket.IO attaches to the same HTTP server and listens for WebSocket connections. When the client-side app.js calls io(), it establishes a persistent connection that logs to your terminal. In the next steps, you will add real messaging logic inside that connection handler.
- Save server.js.
- Start your server by running this command in the terminal:
node server.js
- You should see this output in your terminal:
Server running on http://localhost:3000
- Open your browser and go to http://localhost:3000.
You should see a dark-themed login screen with a green "WhatsApp System Design" heading, a username input field, and a Sign In button. Back in your terminal, you should also see a log line that says Socket connected: followed by a socket ID.
Page not loading or no Socket connected log?
Make sure your terminal is still running node server.js (it should not have exited). Check the URL is exactly http://localhost:3000 with no typos.
If you see an error like Cannot find module 'express', make sure you ran npm install from inside the whatsapp-system folder. The node_modules folder must be in the same directory as server.js.
help me debug my Node.js server not starting
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here is the complete server.js you should have at this point:
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static('public'));
io.on('connection', (socket) => {
console.log('Socket connected:', socket.id);
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Your public/ folder should contain index.html, style.css, and app.js with the exact code provided above.
Your environment is ready and the client-server connection is live. Next up, you'll build the connection registry and presence system that tracks who is online.
Build the Connection Registry and Presence System
Your server accepts connections, but it has no idea who is connected. Every socket looks the same.
In this step, you will build the system that tracks which users are online and announces their presence to everyone else. This is the foundation that all message routing depends on.
In this step, get ready to:
- Build a connection registry that maps usernames to socket IDs.
- Implement presence broadcasting so users know who is online.
- Test the system with multiple browser tabs acting as different users.
Implement user registration and connection mapping
When someone opens WhatsApp, their phone establishes a persistent WebSocket connection to a server. The server needs to know which connection belongs to which user so it can route messages to the right place.
What is a connection registry?
A connection registry is a lookup table that maps user identities to their active network connections. When a message arrives for "Alice," the server checks the registry to find Alice's socket and delivers directly to it.
At WhatsApp scale, this registry lives in Redis and maps user IDs to specific server instances across thousands of machines. Your implementation uses a JavaScript Map to achieve the same pattern at a learnable scale.
- In VS Code, open server.js.
- Find the line that says app.use(express.static('public'));.
- Add the connection registry Map directly below it by copying this code:
// Connection registry: username -> socketId
const connectedUsers = new Map();
- Now find the io.on('connection', (socket) => { block. Inside it, after the existing console.log('Socket connected:', socket.id); line, add the user registration handler:
// User registration
socket.on('register', (username) => {
socket.username = username;
connectedUsers.set(username, socket.id);
console.log(`${username} registered (${socket.id})`);
// Broadcast presence to all other users
socket.broadcast.emit('user-online', username);
// Send current online users to this client
const onlineUsers = Array.from(connectedUsers.keys());
socket.emit('online-users', onlineUsers);
});
What does this code do?
- socket.on('register', ...) listens for the client to send its username after connecting.
- socket.username = username stores the username directly on the socket object so you can access it later during disconnect.
- connectedUsers.set(username, socket.id) adds the user to the registry, mapping their name to their socket ID for routing.
- socket.broadcast.emit('user-online', username) tells every OTHER connected client that this user just came online.
- socket.emit('online-users', onlineUsers) sends the full list of who is currently connected back to ONLY the registering client.
- Save server.js.
You won't see a change yet. The disconnect handler in the next substep completes the presence system.
Implement presence broadcasting on disconnect
Registration handles the "coming online" case. You also need to handle when a user disappears. Closing a browser tab severs the WebSocket connection, and the server must clean up the registry and tell everyone else.
- In server.js, add the disconnect handler inside the io.on('connection', ...) block, directly after the closing }); of the register handler:
// Disconnect handling
socket.on('disconnect', () => {
if (socket.username) {
connectedUsers.delete(socket.username);
io.emit('user-offline', socket.username);
console.log(`${socket.username} disconnected`);
}
});
What does this code do?
- socket.on('disconnect', ...) fires automatically when a socket's connection drops (tab closed, network lost).
- if (socket.username) checks whether this socket had registered a user. Anonymous connections that never registered get skipped.
- connectedUsers.delete(socket.username) removes the user from the registry so no one tries to route messages to a dead socket.
- io.emit('user-offline', socket.username) broadcasts to ALL connected clients that this user is gone. Notice this uses io.emit (everyone) instead of socket.broadcast.emit (everyone except sender) because the sender is already gone.
Why broadcast to everyone instead of just contacts?
At WhatsApp scale, presence changes use pub/sub channels (Redis Pub/Sub or Kafka) to fan out only to a user's contacts. Broadcasting to everyone is an intentional simplification here.
In production with billions of users, telling EVERY user that someone went offline would be catastrophically expensive. The architecture pattern is the same. The scope of who receives the event is what changes.
- Save server.js.
- Stop your running server by pressing Ctrl+C in the terminal, then restart it by running:
node server.js
- Open http://localhost:3000 in your browser.
- Type a username (like Alice) in the input field and click Sign In.
- Check your terminal. You should see Alice registered followed by a socket ID in parentheses.
Not seeing the registration log?
- Make sure you restarted the server after saving. Node.js does not auto-reload on file changes.
- Check that the socket.on('register', ...) handler is inside the io.on('connection', ...) callback, not outside it.
- Look for syntax errors in the terminal when you run node server.js. A missing comma or bracket will prevent the server from starting.
Why isn't my register event handler firing when I sign in?
Test presence with multiple browser tabs
The presence system only becomes interesting when multiple users are connected at once. You will simulate this using separate browser tabs, each acting as a different user.
- Open a second browser tab at http://localhost:3000.
- Sign in as Bob.
- Check your terminal. You should see Bob registered in the logs.
- Switch back to Alice's tab. You should see Bob appear in the Online Users sidebar with a green dot.
- Open a third tab and sign in as Charlie.
- Verify that Charlie's tab shows both Alice and Bob in the sidebar.
- Now close Bob's tab entirely.
- Check the terminal for a Bob disconnected log.
- Switch to Alice's or Charlie's tab and confirm that Bob has disappeared from the Online Users sidebar.
What just happened?
You just witnessed the full presence lifecycle. When Bob registered, the server added him to the registry and broadcast user-online to Alice. When Bob closed his tab, the server removed him from the registry and broadcast user-offline to everyone remaining.
The pre-built client listens for these events and updates the sidebar automatically. Your server is doing all the work.
Bob not appearing in Alice's sidebar?
- Make sure Alice was already signed in BEFORE Bob registered. The user-online event only reaches clients that are already connected.
- Confirm that socket.broadcast.emit is spelled correctly in your register handler. A typo means the event never fires.
- Check that the terminal shows both users registered. If only one appears, the other tab may not have completed sign-in.
Why don't my users see each other in the sidebar?
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js and that your presence system is working with multiple tabs.
ⓧ I'd like to double check the full code
Here is the complete server.js at this point in the project:
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static('public'));
// Connection registry: username -> socketId
const connectedUsers = new Map();
io.on('connection', (socket) => {
console.log('Socket connected:', socket.id);
// User registration
socket.on('register', (username) => {
socket.username = username;
connectedUsers.set(username, socket.id);
console.log(`${username} registered (${socket.id})`);
// Broadcast presence to all other users
socket.broadcast.emit('user-online', username);
// Send current online users to this client
const onlineUsers = Array.from(connectedUsers.keys());
socket.emit('online-users', onlineUsers);
});
// Disconnect handling
socket.on('disconnect', () => {
if (socket.username) {
connectedUsers.delete(socket.username);
io.emit('user-offline', socket.username);
console.log(`${socket.username} disconnected`);
}
});
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Your server now knows exactly who is online and can tell the world when someone arrives or leaves. Next up, you will use this registry to route messages between users and implement delivery receipts.
Implement Message Routing with Delivery Receipts
Your server can track who is online, but it cannot yet deliver messages between them.
In a real messaging system, the server sits between sender and recipient. It receives a message, looks up where the recipient is connected, and routes it directly to them. If the recipient is offline, the message goes into a queue until they reconnect. In this step, you will build this entire routing layer plus the three-stage delivery receipt system that WhatsApp uses.
In this step, get ready to:
- Build the message routing logic that delivers messages to online recipients.
- Implement three-stage delivery receipts (sent, delivered, read).
- Add offline message queuing so messages are never lost.
Build the message send and route flow
The core job of a messaging server is routing. When a user sends a message, the server needs to look up the recipient's socket connection and forward the message directly to them.
You also need a place to store messages for users who are not currently online. This offline queue ensures no messages are lost.
- In server.js, find this line:
// Connection registry: username -> socketId
const connectedUsers = new Map();
- Add the offline message queue directly below it, so both Maps sit together:
// Offline message queue: username -> [messages]
const offlineMessages = new Map();
Now add the message routing handler. This is the code that receives a message from the sender and decides where it goes.
- In server.js, find the closing }); of the register event handler.
- Add the following message routing handler below it (but still inside the io.on('connection') callback):
// Message routing
socket.on('send-message', (data) => {
const { to, content, messageId } = data;
const from = socket.username;
// Acknowledge to sender: message received by server (single check)
socket.emit('message-sent', { messageId });
const message = { from, content, messageId, timestamp: Date.now() };
const recipientSocketId = connectedUsers.get(to);
if (recipientSocketId) {
// Recipient is online: route directly
io.to(recipientSocketId).emit('receive-message', message);
} else {
// Recipient is offline: queue the message
if (!offlineMessages.has(to)) {
offlineMessages.set(to, []);
}
offlineMessages.get(to).push(message);
console.log(`Queued message for offline user: ${to}`);
}
});
What does this code do?
This handler fires every time a client sends a message. Here is the flow:
- socket.emit('message-sent', { messageId }) immediately tells the sender "I got your message" (the single grey check in WhatsApp).
- connectedUsers.get(to) looks up the recipient's socket ID in the connection registry.
- If the recipient is online, the message routes directly to their socket via io.to(recipientSocketId).emit().
- If the recipient is offline, the message gets pushed into offlineMessages for later delivery.
How does WhatsApp route messages at scale?
Your connectedUsers Map lives in memory on a single server. At WhatsApp scale, this registry lives in Redis and maps user IDs to specific WebSocket server instances across thousands of machines.
The routing logic is identical. Look up where the user is connected, send the message there.
- Save server.js.
Seeing a syntax error when you save?
- Make sure you placed the new handler inside the io.on('connection') callback, not outside it.
- Check that the closing }); of the register handler is still intact above your new code.
- Confirm you have matching curly braces and parentheses for the new send-message handler.
Help me debug a syntax error in my server.js message routing handler.
Implement delivered and read receipts
WhatsApp shows three stages of message delivery: a single grey check (server received), double grey checks (recipient's device received), and blue double checks (recipient read the message).
The server does not interpret these receipts. It acts as a blind router, forwarding receipt signals from the recipient back to the original sender.
- In server.js, add the following two receipt handlers below the send-message handler you just added:
// Delivery receipt: recipient confirms they received the message
socket.on('message-delivered', (data) => {
const { messageId, to } = data;
const senderSocketId = connectedUsers.get(to);
if (senderSocketId) {
io.to(senderSocketId).emit('message-delivered', { messageId });
}
});
// Read receipt: recipient has viewed the message
socket.on('message-read', (data) => {
const { messageId, to } = data;
const senderSocketId = connectedUsers.get(to);
if (senderSocketId) {
io.to(senderSocketId).emit('message-read', { messageId });
}
});
What does this code do?
Both handlers follow the same pattern:
- The recipient's client emits a receipt event (message-delivered or message-read) with the messageId and the original sender's username in to.
- The server looks up the original sender in connectedUsers and forwards the receipt to them.
- The sender's client updates the tick marks in the UI from grey to blue.
With end-to-end encryption, WhatsApp's server cannot read message content anyway. It just routes ciphertext and receipt signals.
- Save server.js.
Receipts not updating in the UI?
- Check that the to field in the receipt data refers to the original sender's username, not the recipient.
- Make sure both handlers are inside the io.on('connection') callback.
Help me debug why my delivery receipts are not forwarding correctly.
Add offline message queuing
You already have the queuing logic in your send-message handler (the else branch pushes messages to offlineMessages). The missing piece is draining that queue when the user comes back online.
- In server.js, find the register event handler.
- Find this line inside it:
socket.emit('online-users', onlineUsers);
- Add the following offline message drain code directly below that line:
// Deliver any offline messages
if (offlineMessages.has(username)) {
const messages = offlineMessages.get(username);
messages.forEach((msg) => {
socket.emit('receive-message', msg);
});
offlineMessages.delete(username);
console.log(`Delivered ${messages.length} offline messages to ${username}`);
}
What does this code do?
When a user registers (reconnects), this code checks if any messages were queued for them while they were away.
- offlineMessages.has(username) checks if the queue has anything waiting.
- Each queued message is emitted as a receive-message event, so the client treats them identically to live messages.
- The queue is deleted after draining so messages are not delivered twice.
At WhatsApp scale, offline messages sit in Kafka partitioned by recipient ID with a 30-day retention window. When the user reconnects, queued messages drain in order.
- Save server.js.
- Stop your running server with Ctrl+C in the terminal, then restart it by running:
node server.js
- Open two browser tabs to http://localhost:3000.
- Register as alice in Tab 1 and bob in Tab 2.
- In Tab 1, click on bob in the sidebar and send a message.
- Watch Tab 2. You should see the message appear instantly and the tick in Tab 1 should progress from a single grey check to double blue checks.
Now test offline queuing.
- Close Tab 2 (bob goes offline).
- In Tab 1, send another message to bob. You should see a single grey check (server received it) but no double check (bob is offline).
- Open a new tab to http://localhost:3000 and register as bob again.
- The queued message should appear immediately in bob's chat. Check the terminal for a log line confirming offline delivery.
Messages not arriving after reconnect?
- Make sure you registered with the exact same username (bob) in the new tab. Usernames are case-sensitive.
- Verify that the offline drain code is inside the register handler, after the socket.emit('online-users', onlineUsers) line.
- Check the terminal for the Queued message for offline user log when you sent the message. If it is missing, the message was routed live instead of queued.
Help me debug why offline messages are not being delivered when the user reconnects.
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js and restarted the server before moving on.
ⓧ I'd like to double check the full code
Here is the complete server.js at the end of this step:
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static('public'));
// Connection registry: username -> socketId
const connectedUsers = new Map();
// Offline message queue: username -> [messages]
const offlineMessages = new Map();
io.on('connection', (socket) => {
console.log('Socket connected:', socket.id);
// User registration
socket.on('register', (username) => {
socket.username = username;
connectedUsers.set(username, socket.id);
console.log(`${username} registered (${socket.id})`);
// Broadcast presence to all other users
socket.broadcast.emit('user-online', username);
// Send current online users to this client
const onlineUsers = Array.from(connectedUsers.keys());
socket.emit('online-users', onlineUsers);
// Deliver any offline messages
if (offlineMessages.has(username)) {
const messages = offlineMessages.get(username);
messages.forEach((msg) => {
socket.emit('receive-message', msg);
});
offlineMessages.delete(username);
console.log(`Delivered ${messages.length} offline messages to ${username}`);
}
});
// Message routing
socket.on('send-message', (data) => {
const { to, content, messageId } = data;
const from = socket.username;
// Acknowledge to sender: message received by server (single check)
socket.emit('message-sent', { messageId });
const message = { from, content, messageId, timestamp: Date.now() };
const recipientSocketId = connectedUsers.get(to);
if (recipientSocketId) {
// Recipient is online: route directly
io.to(recipientSocketId).emit('receive-message', message);
} else {
// Recipient is offline: queue the message
if (!offlineMessages.has(to)) {
offlineMessages.set(to, []);
}
offlineMessages.get(to).push(message);
console.log(`Queued message for offline user: ${to}`);
}
});
// Delivery receipt: recipient confirms they received the message
socket.on('message-delivered', (data) => {
const { messageId, to } = data;
const senderSocketId = connectedUsers.get(to);
if (senderSocketId) {
io.to(senderSocketId).emit('message-delivered', { messageId });
}
});
// Read receipt: recipient has viewed the message
socket.on('message-read', (data) => {
const { messageId, to } = data;
const senderSocketId = connectedUsers.get(to);
if (senderSocketId) {
io.to(senderSocketId).emit('message-read', { messageId });
}
});
// Disconnect handling
socket.on('disconnect', () => {
if (socket.username) {
connectedUsers.delete(socket.username);
io.emit('user-offline', socket.username);
console.log(`${socket.username} disconnected`);
}
});
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Your messaging server now handles real-time routing, offline queuing, and three-stage delivery receipts. Next up, you will test the full system end-to-end and map each component to WhatsApp's production architecture.
Test the Full System and Map to WhatsApp Architecture
You have built every piece of the messaging backend: connection management, message routing, presence detection, delivery receipts, and offline queuing.
Now it is time to prove the system works end-to-end and map what you built to the real WhatsApp architecture. This gives you the mental model and vocabulary to discuss messaging system design in interviews.
In this step, get ready to:
- Test the full message lifecycle across three users with receipt progression.
- Verify offline message queuing by disconnecting and reconnecting a user.
- Map each component you built to its WhatsApp-scale equivalent.
Test the complete message lifecycle
The full message lifecycle includes sending a message, seeing it route to the recipient, and watching the receipt indicators progress through all three stages.
- Make sure your server is running. If it is not, start it by running:
node server.js
- Open three separate browser tabs, all pointing to http://localhost:3000.
- In Tab 1, type Alice in the username field and click Sign In.
- In Tab 2, sign in as Bob.
- In Tab 3, sign in as Charlie.
You should see all three usernames appear in each tab's sidebar under Online Users.
- In Alice's tab, click Bob in the sidebar to start a chat.
- Type a message (e.g. Hey Bob!) and click Send.
- Watch the tick indicator next to your message.
What should I see?
The tick indicator progresses through three stages:
- A single grey tick appears almost instantly. This means the server received the message (the message-sent acknowledgement).
- Double grey ticks appear immediately after. This means Bob's client received it (the message-delivered event).
- If Bob has Alice's chat open, the ticks turn blue. This means Bob read it (the message-read event).
- Switch to Bob's tab and click Alice in the sidebar.
- Confirm that Alice's message appears in the chat panel.
- Switch back to Alice's tab and confirm the ticks are now blue (read).
- Send a message from Bob to Charlie to confirm routing works between any pair of users.
Messages not appearing?
- Check your terminal for error messages. A common cause is the server crashing from a typo in server.js.
- Make sure each tab is signed in with a different username. Using the same username in two tabs overwrites the first connection in the registry.
- Confirm that you clicked on the recipient's name in the sidebar before sending. The input area only appears after selecting a chat.
Help me debug why messages aren't routing between users in my WhatsApp system.
Now test offline message queuing.
- Close Charlie's browser tab (Tab 3).
- In Alice's tab, click Charlie in the sidebar and send a message like Are you there?.
- Notice the tick stays as a single grey check. The server received it but cannot deliver it because Charlie is offline.
- Open a new tab to http://localhost:3000 and sign in as Charlie again.
- Charlie's tab should immediately show Alice's queued message.
What happened behind the scenes?
When Charlie was offline, the server pushed the message into the offlineMessages Map. When Charlie registered again, the server drained the queue and emitted each stored message via receive-message.
Check your terminal. You should see a log line like Delivered 1 offline messages to Charlie.
Draw the architecture diagram
Every component you built maps directly to a production system at WhatsApp scale. Drawing this out solidifies the connection between your code and the real architecture.
- Grab a piece of paper, open a notes app, or use any drawing tool you prefer.
- Draw the following components with arrows showing data flow between them:
- Clients (browser tabs) connecting via WebSocket to the Socket.IO server.
- The Connection Registry (connectedUsers Map) sitting inside the server.
- The Message Router that looks up recipients and forwards messages.
- The Offline Queue (offlineMessages Map) that stores messages for disconnected users.
- The Presence Service that broadcasts online/offline events.
- Label each component with what it maps to at WhatsApp scale:
- Socket.IO server = WebSocket Gateway Cluster (thousands of servers handling millions of connections).
- connectedUsers Map = Redis connection registry (maps user IDs to specific server instances).
- offlineMessages Map = Kafka message queue (partitioned by recipient, 30-day retention).
- Presence broadcasts = Redis Pub/Sub (fan out status changes to interested contacts only).
Why does this mapping matter?
In a system design interview, you are expected to name specific technologies and justify their use. Saying "I would use Redis for the connection registry because it provides O(1) lookups and handles millions of keys" is far stronger than "I would use a database."
Your diagram is a reference you can study before interviews. Keep it somewhere accessible.
Document system design trade-offs for interview prep
The difference between a good and great system design answer is articulating trade-offs. Write brief notes answering these questions.
- Open a new file or notes document for your trade-off notes.
- Write 1-2 sentences for each of the following questions:
- Why WebSocket over HTTP polling? Think about latency, server load, and the always-on nature of a chat connection.
- Why a connection registry? Consider how the server knows which socket belongs to which user when a message arrives.
- Why decouple offline storage from live routing? Think about what happens if you tried to hold messages in the socket layer itself.
- What changes at 1M users vs 1B users? Consider horizontal scaling, cross-server routing, and database partitioning.
- Add a section to your notes listing what this simplified version omits:
- End-to-end encryption (WhatsApp uses the Signal Protocol).
- Database persistence (WhatsApp uses Cassandra partitioned by chat ID and time bucket).
- Horizontal scaling (multiple server instances with a Redis adapter for cross-server message routing).
- Media handling (separate upload/download paths to object storage).
- Group fan-out (you will tackle this in the Secret Mission).
Why list what you omitted?
Interviewers often follow up with "what would you add next?" or "what are you missing?" Having a ready list of omissions shows you understand the full scope of the problem, not just the slice you implemented.
Your messaging backend is fully tested and documented. Next up, the Secret Mission challenges you to add group messaging and experience the fan-out problem firsthand.
Secret mission
Add Group Messaging with Fan-Out
What happens when you send a single message to a group of 50 people? The server has to deliver that message to each member individually. This is called the fan-out problem, and it's one of the most discussed trade-offs in system design interviews. Add group messaging to your server and experience the complexity firsthand.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing cloud costs.
Resources you used:
- The whatsapp-system project folder (including node_modules, server.js, and public/ files).
- The Node.js server process running on port 3000.
Keep everything running
No action needed. Choose this if you are still actively building or testing your messaging system.
- Your server continues running on http://localhost:3000 until you stop it or close the terminal.
- Your project files remain in the whatsapp-system folder on your Desktop.
Pause - I'll come back to this later
Stop the running server process to free up port 3000. Your project files stay on disk so you can restart anytime.
- Go to the terminal where your server is running.
- Press Ctrl+C to stop the server process.
- To restart later, navigate back to the whatsapp-system folder and run node server.js again.
Delete - I don't want to use this again
Remove all project files and stop any running processes. This cannot be undone.
- Go to the terminal where your server is running and press Ctrl+C to stop the server.
- Delete the project folder by running the following command (macOS/Linux):
rm -rf ~/Desktop/whatsapp-system
On Windows, run this in PowerShell instead:
Remove-Item -Recurse -Force ~\Desktop\whatsapp-system
- Confirm the folder is gone by checking your Desktop.
Nice Work!
Nice Work!
You just built a real-time messaging backend that implements the same architectural patterns as WhatsApp. You now have working code that proves you understand connection management, message routing, presence, delivery receipts, and offline queuing.
Here's what you accomplished:
- Built a WebSocket server with a connection registry that maps users to sockets, mirroring how WhatsApp routes messages through Redis at scale.
- Implemented three-stage delivery receipts (sent, delivered, read) and offline message queuing so messages are never lost, matching WhatsApp's exact tick system and Kafka-based storage.
- Created a presence system with online/offline detection and broadcasting, the same pattern WhatsApp uses with pub/sub at scale.
- Added group messaging with fan-out to understand write amplification and the trade-offs that dominate system design interviews.
Ready to quiz yourself?