Build a Real-Time Chat App
Build a WebSocket chat app with a Node.js server and React TypeScript UI.
Introduction
30 Second Summary
Every time you send a message on WhatsApp, Slack, or Discord, it appears on the other person's screen instantly. No refreshing, no waiting. That magic is powered by WebSockets.
In this project, you will build a real-time chat application from scratch using TypeScript, Node.js, and React. You'll create both the server that manages connections and the frontend that displays messages the instant they arrive.
What You'll Build
A WhatsApp-style chat application with a Node.js WebSocket server and a React TypeScript frontend that supports multiple simultaneous users, live messaging, and presence tracking.
By the end of this project, you'll have:
- A TypeScript WebSocket server that handles multiple connections and broadcasts messages in real time.
- A React frontend with WhatsApp-style chat bubbles, timestamps, an online users sidebar, and typing indicators.
- A typed message protocol shared between client and server for structured, type-safe communication.
- Secret Mission: Add read receipts (the blue checkmarks) to show when messages have been seen.
Are there any prerequisites?
You should be comfortable with JavaScript fundamentals like variables, functions, and arrays. Basic React knowledge (components, state, and props) is also helpful.
Set Up the Project
A real-time chat app needs two pieces: a server that manages connections and relays messages, and a client that displays the UI.
In this step, you will create a project with separate server and client directories, install the packages each one needs, and define the message types that keep both sides speaking the same language.
In this step, get ready to:
- Initialize the WebSocket server project with TypeScript.
- Scaffold the React client with Vite.
- Define the shared message protocol types.
Create the server project
The server is a standalone Node.js project that will run TypeScript directly using tsx.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and the server directory inside it by running these commands:
mkdir websocket-chat
cd websocket-chat
mkdir server
cd server
- Confirm the directories exist by running:
ls
You should see the server folder listed when you run ls from inside websocket-chat. You are now inside the server directory.
- Initialize a new Node.js project by running:
npm init -y
- Install the WebSocket library as a runtime dependency by running:
npm install ws@8.21.0
- Install the TypeScript toolchain and type definitions as dev dependencies by running:
npm install --save-dev @types/ws@8.18.1 typescript@5.8.3 tsx@4.22.4
Seeing install errors?
Make sure you are inside the server directory when you run these commands. Run pwd to check your current location.
If you see permission errors on macOS, prefix the command with sudo. help me fix npm install permission errors
Now you need to configure the project for ES modules and add a dev script. The npm init command created a basic package.json, but it needs a few changes.
- Open the websocket-chat folder in VS Code. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the Command Palette.
- Type File: Open Folder and press Enter.
- Navigate to Desktop and select the websocket-chat folder, then click Open.
- In the Explorer sidebar on the left, expand the server folder and click on package.json to open it.
- Replace the entire contents of package.json with the following:
{
"name": "chat-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
},
"dependencies": {
"ws": "8.21.0"
},
"devDependencies": {
"@types/ws": "8.18.1",
"typescript": "5.8.3",
"tsx": "4.22.4"
}
}
What does this configuration do?
- "type": "module" tells Node.js to use ES module syntax (import/export) instead of CommonJS (require).
- "dev": "tsx src/index.ts" creates a shortcut to run your server file directly with TypeScript support.
- ws is the WebSocket library for Node.js that handles the protocol-level connection management.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Next, you need a TypeScript configuration file so the compiler knows how to handle your code.
- Right-click on the server folder in the Explorer sidebar and select New File.
- Name it tsconfig.json and press Enter.
- Paste the following configuration into the file:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
What does this TypeScript config do?
- "target": "ES2022" compiles to modern JavaScript that supports features like crypto.randomUUID().
- "module": "ESNext" and "moduleResolution": "bundler" work together to support ES module imports.
- "strict": true enables all TypeScript strictness checks, catching bugs before runtime.
- "rootDir": "./src" tells TypeScript that all source files live inside src/.
- Save tsconfig.json.
- Create a src folder inside server by right-clicking the server folder and selecting New Folder. Name it src.
Scaffold the React client
The client is a separate React app scaffolded by Vite. It lives alongside the server in the same project root.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Navigate to the project root by running:
cd ~/Desktop/websocket-chat
- Scaffold the React client with Vite by running:
npm create vite@latest client -- --template react-ts
What is Vite?
Vite is a modern frontend build tool that provides instant hot module replacement during development. The react-ts template scaffolds a React project pre-configured with TypeScript.
- Move into the new client directory and install its dependencies by running:
cd client
npm install
- Confirm the scaffold succeeded by running:
ls
You should see files like index.html, package.json, src, vite.config.ts, and tsconfig.json listed in the output.
Scaffold command failed?
Vite requires Node.js version 20.19+ or 22.12+. Check your version by running node --version in your terminal. If your version is older, download the latest LTS from the official Node.js website.
If you see a network error, check your internet connection and try running the command again. Help me troubleshoot the Vite scaffold failing
Create the shared types file
Both the server and client need to agree on the shape of every message sent over the WebSocket connection. A shared types file acts as a contract between them.
- In the VS Code Explorer, right-click on the src folder inside server and select New File.
- Name the file types.ts and press Enter.
- Paste the following message protocol definitions into the file:
export interface ChatMessage {
type: "chat";
id: string;
sender: string;
text: string;
timestamp: number;
}
export interface JoinMessage {
type: "join";
username: string;
}
export interface TypingMessage {
type: "typing";
username: string;
isTyping: boolean;
}
export interface UserListMessage {
type: "user_list";
users: string[];
}
export interface ChatBroadcast {
type: "chat_broadcast";
id: string;
sender: string;
text: string;
timestamp: number;
}
export type ClientMessage = JoinMessage | ChatMessage | TypingMessage;
export type ServerMessage = UserListMessage | ChatBroadcast | TypingMessage;
What does this types file do?
Each interface describes one kind of WebSocket message. The type field on every interface is a string literal that lets you identify which message you received.
- ClientMessage is a union of everything a browser can send to the server: joining, chatting, or typing.
- ServerMessage is a union of everything the server can push to a browser: user list updates, chat broadcasts, or typing notifications.
- TypeScript uses the type field as a discriminator. When you switch on message.type, the compiler narrows the type automatically.
- Save types.ts.
- Check the VS Code Explorer sidebar. You should see both server/ and client/ directories at the top level, with server/src/types.ts visible inside the server folder.
Don't see both folders in the sidebar?
Make sure you opened the websocket-chat folder itself in VS Code (not the server or client subfolder). You should see both directories listed at the root of the Explorer panel.
help me fix my VS Code project structure
✔️ Awesome, I've got everything!
Great. Double check that you have saved all three files: server/package.json, server/tsconfig.json, and server/src/types.ts.
ⓧ I'd like to double check the full code
Here are the three files you should have in your server/ directory:
{
"name": "chat-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
},
"dependencies": {
"ws": "8.21.0"
},
"devDependencies": {
"@types/ws": "8.18.1",
"typescript": "5.8.3",
"tsx": "4.22.4"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
export interface ChatMessage {
type: "chat";
id: string;
sender: string;
text: string;
timestamp: number;
}
export interface JoinMessage {
type: "join";
username: string;
}
export interface TypingMessage {
type: "typing";
username: string;
isTyping: boolean;
}
export interface UserListMessage {
type: "user_list";
users: string[];
}
export interface ChatBroadcast {
type: "chat_broadcast";
id: string;
sender: string;
text: string;
timestamp: number;
}
export type ClientMessage = JoinMessage | ChatMessage | TypingMessage;
export type ServerMessage = UserListMessage | ChatBroadcast | TypingMessage;
Your project skeleton is ready. Next up, you will build the WebSocket server that accepts connections and broadcasts messages to every connected client.
Build the WebSocket Server
You have the message types defined and the project scaffolded. Now it's time to write the server that actually receives connections and routes messages between users.
A WebSocket server keeps a persistent connection open with each client. When one user sends a message, the server pushes it to everyone else instantly. In this step, you will build that server from scratch using the ws library and the typed interfaces you created in the previous step.
In this step, get ready to:
- Create the WebSocket server with client tracking and broadcast helpers.
- Implement message routing for join, chat, and typing events.
- Handle disconnections and test the server.
Create the server infrastructure
Before handling any connections, the server needs a foundation: the WebSocket server instance, a way to track who is connected, and helper functions to broadcast messages.
- In VS Code, create a new file called index.ts inside the server/src/ directory.
- Add the following code to set up the server, client tracking, and broadcast helpers:
import { WebSocketServer, WebSocket } from "ws";
import type { ClientMessage, ServerMessage } from "./types.js";
// Create a WebSocket server listening on port 8080
const wss = new WebSocketServer({ port: 8080 });
// Shape of each connected client
interface Client {
ws: WebSocket;
username: string;
}
// Track all connected clients by unique ID
const clients = new Map<string, Client>();
// Send a message to all connected clients (optionally excluding one)
function broadcast(message: ServerMessage, exclude?: string): void {
const data = JSON.stringify(message);
clients.forEach((client, id) => {
if (id !== exclude && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(data);
}
});
}
// Send the current user list to every connected client
function broadcastUserList(): void {
const users = Array.from(clients.values()).map((c) => c.username);
const message: ServerMessage = { type: "user_list", users };
const data = JSON.stringify(message);
clients.forEach((client) => {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(data);
}
});
}
What does this code do?
The WebSocketServer from the ws library creates a server that listens for incoming WebSocket connections on port 8080.
The clients Map associates a unique ID with each connected user's WebSocket and username. This lets you target or exclude specific clients when broadcasting.
The broadcast() function sends a message to everyone (or everyone except one client). The broadcastUserList() function collects all usernames and pushes the list to every client so the UI can show who is online.
Why import from ./types.js instead of ./types.ts?
TypeScript with ESM modules requires you to use the .js extension in import paths, even though the source file is .ts. This is because TypeScript resolves imports based on the compiled output, where the file will be .js.
- Save server/src/index.ts.
Seeing red underlines in VS Code?
If VS Code highlights the ws import, make sure you ran npm install inside the server directory in the previous step. The @types/ws package provides the type definitions VS Code needs.
Help me fix import errors in my WebSocket server file.
Implement message handling
When a client connects, the server needs to listen for messages, figure out what type each message is, and respond accordingly. This is where your typed interfaces pay off.
- Add the connection handler below the broadcastUserList function in server/src/index.ts:
wss.on("connection", (ws) => {
// Generate a unique ID for this connection
const clientId = crypto.randomUUID();
let username = "";
ws.on("message", (raw) => {
try {
const message: ClientMessage = JSON.parse(raw.toString());
switch (message.type) {
case "join":
username = message.username;
clients.set(clientId, { ws, username });
broadcastUserList();
break;
case "chat":
const chatBroadcast: ServerMessage = {
type: "chat_broadcast",
id: message.id,
sender: message.sender,
text: message.text,
timestamp: message.timestamp,
};
broadcast(chatBroadcast);
break;
case "typing":
const typingBroadcast: ServerMessage = {
type: "typing",
username: message.username,
isTyping: message.isTyping,
};
broadcast(typingBroadcast, clientId);
break;
}
} catch (error) {
console.error("Failed to parse message:", error);
}
});
});
console.log("WebSocket server running on ws://localhost:8080");
What does this code do?
Every new WebSocket connection triggers the connection event. Inside it, crypto.randomUUID() generates a unique ID for tracking that client.
The message event fires whenever a client sends data. The switch statement routes each message by its type field:
- join stores the username and tells everyone the user list changed.
- chat repackages the message as a chat_broadcast and sends it to all clients.
- typing forwards the typing status to everyone except the sender (you don't need to see your own typing indicator).
- Save server/src/index.ts.
Seeing an error on crypto.randomUUID()?
The crypto.randomUUID() function is available natively in Node.js 19+. If VS Code shows a type error, check that your tsconfig.json has "target": "ES2022" which includes the crypto global types.
Help me fix the crypto.randomUUID type error in my server.
Handle disconnections and test the server
Right now the server handles incoming messages, but it never cleans up when someone disconnects. Without a close handler, the user list would show ghost users who already left.
- In server/src/index.ts, find the closing of the ws.on("message", ...) handler. It looks like this:
}
});
});
- Replace those last three lines with the following to add the close and error handlers before closing the connection callback:
}
});
ws.on("close", () => {
clients.delete(clientId);
broadcastUserList();
});
ws.on("error", console.error);
});
console.log("WebSocket server running on ws://localhost:8080");
What does this code do?
The close event fires when a client disconnects (closes the tab, loses internet, etc.). It removes the client from the clients Map and broadcasts an updated user list so everyone's sidebar reflects the change immediately.
The error handler logs any unexpected socket errors to the console, preventing unhandled exceptions from crashing the server.
- Save server/src/index.ts.
- In your terminal, make sure you are inside the server directory, then start the server by running:
npx tsx src/index.ts
You should see WebSocket server running on ws://localhost:8080 printed in the terminal.
Server not starting?
If you see a "Cannot find module" error, double check you are running the command from inside the server folder (not the project root or the client folder).
If you see "Address already in use", another process is using port 8080. Close any other servers running on that port and try again.
Help me troubleshoot my WebSocket server startup error.
- Once you confirm the server is running, press Ctrl+C to stop it. You will start it again when testing with the frontend.
✔️ Awesome, I've got everything!
Great. Double check you have saved server/src/index.ts before moving on.
ⓧ I'd like to double check the full code
import { WebSocketServer, WebSocket } from "ws";
import type { ClientMessage, ServerMessage } from "./types.js";
const wss = new WebSocketServer({ port: 8080 });
interface Client {
ws: WebSocket;
username: string;
}
const clients = new Map<string, Client>();
function broadcast(message: ServerMessage, exclude?: string): void {
const data = JSON.stringify(message);
clients.forEach((client, id) => {
if (id !== exclude && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(data);
}
});
}
function broadcastUserList(): void {
const users = Array.from(clients.values()).map((c) => c.username);
const message: ServerMessage = { type: "user_list", users };
const data = JSON.stringify(message);
clients.forEach((client) => {
if (client.ws.readyState === WebSocket.OPEN) {
client.ws.send(data);
}
});
}
wss.on("connection", (ws) => {
const clientId = crypto.randomUUID();
let username = "";
ws.on("message", (raw) => {
try {
const message: ClientMessage = JSON.parse(raw.toString());
switch (message.type) {
case "join":
username = message.username;
clients.set(clientId, { ws, username });
broadcastUserList();
break;
case "chat":
const chatBroadcast: ServerMessage = {
type: "chat_broadcast",
id: message.id,
sender: message.sender,
text: message.text,
timestamp: message.timestamp,
};
broadcast(chatBroadcast);
break;
case "typing":
const typingBroadcast: ServerMessage = {
type: "typing",
username: message.username,
isTyping: message.isTyping,
};
broadcast(typingBroadcast, clientId);
break;
}
} catch (error) {
console.error("Failed to parse message:", error);
}
});
ws.on("close", () => {
clients.delete(clientId);
broadcastUserList();
});
ws.on("error", console.error);
});
console.log("WebSocket server running on ws://localhost:8080");
Your server is ready to handle real-time connections. Next up, you will build the React frontend that connects to it and displays messages in a WhatsApp-style chat UI.
Build the Chat UI
Your server is running and ready to handle connections. Now you need a frontend that connects to it, displays messages in real time, and shows who is online.
In this step, you will build a React component that manages WebSocket connection state, renders a WhatsApp-style chat layout, and sends typing indicators with a debounce pattern.
In this step, get ready to:
- Create the main App component with WebSocket connection state management.
- Build a WhatsApp-style chat interface with a sidebar, message bubbles, and an input bar.
- Implement a typing indicator with debounced stop-typing events.
Create the main App component with WebSocket connection state
The browser has its own native WebSocket API. Your React component will use it to open a persistent connection to the server, send a join message with the chosen username, and react to incoming messages by updating state.
- In VS Code, open client/src/App.tsx from the Explorer sidebar.
- Select all the existing Vite scaffold code and delete it.
- Replace it with the following code that sets up the connection logic:
import { useState, useEffect, useRef } from "react";
import "./App.css";
interface ChatMessage {
id: string;
sender: string;
text: string;
timestamp: number;
}
type ServerMessage =
| { type: "user_list"; users: string[] }
| { type: "chat_broadcast"; id: string; sender: string; text: string; timestamp: number }
| { type: "typing"; username: string; isTyping: boolean };
function App() {
const [username, setUsername] = useState("");
const [joined, setJoined] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [input, setInput] = useState("");
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
function connect(name: string) {
const ws = new WebSocket("ws://localhost:8080");
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: "join", username: name }));
setJoined(true);
};
ws.onmessage = (event) => {
const message: ServerMessage = JSON.parse(event.data);
switch (message.type) {
case "user_list":
setUsers(message.users);
break;
case "chat_broadcast":
setMessages((prev) => [
...prev,
{
id: message.id,
sender: message.sender,
text: message.text,
timestamp: message.timestamp,
},
]);
setTypingUsers((prev) => prev.filter((u) => u !== message.sender));
break;
case "typing":
setTypingUsers((prev) => {
if (message.isTyping) {
return prev.includes(message.username) ? prev : [...prev, message.username];
}
return prev.filter((u) => u !== message.username);
});
break;
}
};
ws.onclose = () => {
setJoined(false);
};
}
What does this code do?
This sets up the entire connection layer for your chat client.
- ChatMessage and ServerMessage define the shape of messages your client can receive from the server. TypeScript will enforce these structures at compile time.
- The six state variables track everything the UI needs: the username, whether the user has joined, the message history, who is online, the current input text, and who is currently typing.
- wsRef holds the WebSocket connection across re-renders. messagesEndRef is an anchor element at the bottom of the message list so the chat auto-scrolls on new messages.
- The connect function opens a WebSocket to your server on port 8080, immediately sends a join message with the username, and routes incoming messages by their type field to update the correct React state.
You won't see a change in the browser yet. The component still needs its event handlers and JSX to render anything.
Build the chat interface with a WhatsApp-style layout
With the connection logic in place, you need functions that send messages and format timestamps, plus the full JSX that renders the join screen and chat layout.
- In client/src/App.tsx, add the following sendMessage function directly below the closing brace of the connect function:
function sendMessage(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || !wsRef.current) return;
const message = {
type: "chat" as const,
id: crypto.randomUUID(),
sender: username,
text: input.trim(),
timestamp: Date.now(),
};
wsRef.current.send(JSON.stringify(message));
setInput("");
sendTypingStop();
}
What does this code do?
- sendMessage fires when the user submits the chat form. It prevents the default page reload, generates a unique ID with crypto.randomUUID(), and sends the message as JSON over the WebSocket.
- After sending, it clears the input field and immediately stops any active typing indicator so other users don't see "typing..." alongside the message that just arrived.
- Next, add the typing handler functions directly below sendMessage:
function handleInputChange(value: string) {
setInput(value);
if (!wsRef.current) return;
wsRef.current.send(
JSON.stringify({ type: "typing", username, isTyping: true })
);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
sendTypingStop();
}, 1000);
}
function sendTypingStop() {
if (!wsRef.current) return;
wsRef.current.send(
JSON.stringify({ type: "typing", username, isTyping: false })
);
}
What does this code do?
- handleInputChange updates the input state, immediately tells the server "I'm typing", then sets a 1-second timer. If the user keeps typing, the timer resets each keystroke. When the user stops for a full second, the timer fires sendTypingStop to tell the server typing has stopped.
- This pattern is called debouncing. It prevents flooding the server with "stopped typing" messages on every pause between words.
- Add the time formatting helper and the full JSX return below sendTypingStop:
function formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
if (!joined) {
return (
<div className="join-screen">
<div className="join-card">
<h1>WebSocket Chat</h1>
<form
onSubmit={(e) => {
e.preventDefault();
if (username.trim()) connect(username.trim());
}}
>
<input
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
<button type="submit">Join Chat</button>
</form>
</div>
</div>
);
}
return (
<div className="chat-container">
<aside className="sidebar">
<h2>Online ({users.length})</h2>
<ul>
{users.map((user) => (
<li key={user}>
<span className="online-dot"></span>
{user}
{user === username && " (you)"}
</li>
))}
</ul>
</aside>
<main className="chat-main">
<header className="chat-header">
<h2>Chat Room</h2>
</header>
<div className="messages">
{messages.map((msg) => (
<div
key={msg.id}
className={`message ${msg.sender === username ? "own" : "other"}`}
>
<div className="message-bubble">
{msg.sender !== username && (
<span className="message-sender">{msg.sender}</span>
)}
<span className="message-text">{msg.text}</span>
<span className="message-time">{formatTime(msg.timestamp)}</span>
</div>
</div>
))}
{typingUsers.length > 0 && (
<div className="typing-indicator">
{typingUsers.join(", ")} {typingUsers.length === 1 ? "is" : "are"}{" "}
typing...
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="input-bar" onSubmit={sendMessage}>
<input
type="text"
placeholder="Type a message..."
value={input}
onChange={(e) => handleInputChange(e.target.value)}
/>
<button type="submit">Send</button>
</form>
</main>
</div>
);
}
export default App;
What does this JSX do?
- When joined is false, the component renders a join screen with a username input and a button that calls connect.
- Once joined, it renders the full chat layout: a sidebar listing online users with green dots, and a main area containing the message list, typing indicator, and input bar.
- Each message gets a CSS class of own or other based on whether you sent it. This drives the green-right vs white-left bubble styling.
- The typing indicator only renders when typingUsers has entries. It joins the usernames and uses correct grammar ("is" vs "are").
- Save client/src/App.tsx.
The component logic is complete. Next, you need the CSS that gives it the WhatsApp dark theme look.
- Open client/src/App.css in the Explorer sidebar.
- Select all the existing Vite default styles and delete them.
- Replace them with the following WhatsApp-style dark theme CSS:
* {
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;
}
#root {
height: 100vh;
}
.join-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: #111b21;
}
.join-card {
background: #202c33;
padding: 2rem;
border-radius: 8px;
text-align: center;
}
.join-card h1 {
margin-bottom: 1rem;
color: #00a884;
}
.join-card input {
padding: 0.75rem 1rem;
border: none;
border-radius: 4px;
background: #2a3942;
color: #e9edef;
font-size: 1rem;
width: 250px;
margin-bottom: 1rem;
display: block;
}
.join-card button {
padding: 0.75rem 2rem;
background: #00a884;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.join-card button:hover {
background: #008f72;
}
.chat-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 250px;
background: #111b21;
border-right: 1px solid #2a3942;
padding: 1rem;
overflow-y: auto;
}
.sidebar h2 {
font-size: 0.9rem;
color: #8696a0;
margin-bottom: 1rem;
text-transform: uppercase;
}
.sidebar ul {
list-style: none;
}
.sidebar li {
padding: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
}
.online-dot {
width: 8px;
height: 8px;
background: #00a884;
border-radius: 50%;
display: inline-block;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: #0b141a;
}
.chat-header {
padding: 1rem;
background: #202c33;
border-bottom: 1px solid #2a3942;
}
.chat-header h2 {
font-size: 1rem;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message {
display: flex;
margin-bottom: 2px;
}
.message.own {
justify-content: flex-end;
}
.message.other {
justify-content: flex-start;
}
.message-bubble {
max-width: 65%;
padding: 0.5rem 0.75rem;
border-radius: 8px;
position: relative;
}
.message.own .message-bubble {
background: #005c4b;
border-top-right-radius: 0;
}
.message.other .message-bubble {
background: #202c33;
border-top-left-radius: 0;
}
.message-sender {
display: block;
font-size: 0.75rem;
color: #00a884;
font-weight: 600;
margin-bottom: 2px;
}
.message-text {
font-size: 0.9rem;
line-height: 1.4;
margin-right: 3rem;
}
.message-time {
font-size: 0.7rem;
color: #8696a0;
float: right;
margin-top: 4px;
}
.typing-indicator {
font-size: 0.8rem;
color: #8696a0;
font-style: italic;
padding: 0.25rem 0;
}
.input-bar {
display: flex;
padding: 0.75rem 1rem;
background: #202c33;
gap: 0.5rem;
}
.input-bar input {
flex: 1;
padding: 0.75rem 1rem;
border: none;
border-radius: 8px;
background: #2a3942;
color: #e9edef;
font-size: 0.95rem;
outline: none;
}
.input-bar button {
padding: 0.75rem 1.5rem;
background: #00a884;
color: white;
border: none;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
}
.input-bar button:hover {
background: #008f72;
}
What does this CSS do?
This stylesheet recreates the WhatsApp dark theme. The dark background colors (#111b21, #0b141a) and the green accent (#00a884) match WhatsApp's design language.
Your own messages get a dark green bubble (#005c4b) aligned right, and other users' messages get a grey bubble (#202c33) aligned left. The clipped top corner on each bubble points toward the sender.
- Save client/src/App.css.
- Switch to your terminal and navigate into the client directory by running:
cd client
- Start the Vite development server by running:
npm run dev
- Open the URL that Vite displays (typically http://localhost:5173) in your browser.
You should see a dark-themed join screen with a green "WebSocket Chat" heading, a username input field, and a "Join Chat" button.
Seeing a blank page or error?
- Check your terminal for TypeScript errors. A missing closing brace or misplaced function will prevent the page from rendering.
- Make sure you deleted all the original Vite scaffold code before pasting. Leftover imports or JSX from the template will conflict.
- Confirm both App.tsx and App.css are saved. Unsaved CSS changes mean you will see unstyled HTML.
Why isn't my chat UI rendering in the browser?
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete App.tsx file:
import { useState, useEffect, useRef } from "react";
import "./App.css";
interface ChatMessage {
id: string;
sender: string;
text: string;
timestamp: number;
}
type ServerMessage =
| { type: "user_list"; users: string[] }
| { type: "chat_broadcast"; id: string; sender: string; text: string; timestamp: number }
| { type: "typing"; username: string; isTyping: boolean };
function App() {
const [username, setUsername] = useState("");
const [joined, setJoined] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [input, setInput] = useState("");
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
function connect(name: string) {
const ws = new WebSocket("ws://localhost:8080");
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: "join", username: name }));
setJoined(true);
};
ws.onmessage = (event) => {
const message: ServerMessage = JSON.parse(event.data);
switch (message.type) {
case "user_list":
setUsers(message.users);
break;
case "chat_broadcast":
setMessages((prev) => [
...prev,
{
id: message.id,
sender: message.sender,
text: message.text,
timestamp: message.timestamp,
},
]);
setTypingUsers((prev) => prev.filter((u) => u !== message.sender));
break;
case "typing":
setTypingUsers((prev) => {
if (message.isTyping) {
return prev.includes(message.username) ? prev : [...prev, message.username];
}
return prev.filter((u) => u !== message.username);
});
break;
}
};
ws.onclose = () => {
setJoined(false);
};
}
function sendMessage(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || !wsRef.current) return;
const message = {
type: "chat" as const,
id: crypto.randomUUID(),
sender: username,
text: input.trim(),
timestamp: Date.now(),
};
wsRef.current.send(JSON.stringify(message));
setInput("");
sendTypingStop();
}
function handleInputChange(value: string) {
setInput(value);
if (!wsRef.current) return;
wsRef.current.send(
JSON.stringify({ type: "typing", username, isTyping: true })
);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
sendTypingStop();
}, 1000);
}
function sendTypingStop() {
if (!wsRef.current) return;
wsRef.current.send(
JSON.stringify({ type: "typing", username, isTyping: false })
);
}
function formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
if (!joined) {
return (
<div className="join-screen">
<div className="join-card">
<h1>WebSocket Chat</h1>
<form
onSubmit={(e) => {
e.preventDefault();
if (username.trim()) connect(username.trim());
}}
>
<input
type="text"
placeholder="Enter your username"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
<button type="submit">Join Chat</button>
</form>
</div>
</div>
);
}
return (
<div className="chat-container">
<aside className="sidebar">
<h2>Online ({users.length})</h2>
<ul>
{users.map((user) => (
<li key={user}>
<span className="online-dot"></span>
{user}
{user === username && " (you)"}
</li>
))}
</ul>
</aside>
<main className="chat-main">
<header className="chat-header">
<h2>Chat Room</h2>
</header>
<div className="messages">
{messages.map((msg) => (
<div
key={msg.id}
className={`message ${msg.sender === username ? "own" : "other"}`}
>
<div className="message-bubble">
{msg.sender !== username && (
<span className="message-sender">{msg.sender}</span>
)}
<span className="message-text">{msg.text}</span>
<span className="message-time">{formatTime(msg.timestamp)}</span>
</div>
</div>
))}
{typingUsers.length > 0 && (
<div className="typing-indicator">
{typingUsers.join(", ")} {typingUsers.length === 1 ? "is" : "are"}{" "}
typing...
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="input-bar" onSubmit={sendMessage}>
<input
type="text"
placeholder="Type a message..."
value={input}
onChange={(e) => handleInputChange(e.target.value)}
/>
<button type="submit">Send</button>
</form>
</main>
</div>
);
}
export default App;
And here is the complete App.css file:
* {
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;
}
#root {
height: 100vh;
}
.join-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: #111b21;
}
.join-card {
background: #202c33;
padding: 2rem;
border-radius: 8px;
text-align: center;
}
.join-card h1 {
margin-bottom: 1rem;
color: #00a884;
}
.join-card input {
padding: 0.75rem 1rem;
border: none;
border-radius: 4px;
background: #2a3942;
color: #e9edef;
font-size: 1rem;
width: 250px;
margin-bottom: 1rem;
display: block;
}
.join-card button {
padding: 0.75rem 2rem;
background: #00a884;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
.join-card button:hover {
background: #008f72;
}
.chat-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 250px;
background: #111b21;
border-right: 1px solid #2a3942;
padding: 1rem;
overflow-y: auto;
}
.sidebar h2 {
font-size: 0.9rem;
color: #8696a0;
margin-bottom: 1rem;
text-transform: uppercase;
}
.sidebar ul {
list-style: none;
}
.sidebar li {
padding: 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
}
.online-dot {
width: 8px;
height: 8px;
background: #00a884;
border-radius: 50%;
display: inline-block;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: #0b141a;
}
.chat-header {
padding: 1rem;
background: #202c33;
border-bottom: 1px solid #2a3942;
}
.chat-header h2 {
font-size: 1rem;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.message {
display: flex;
margin-bottom: 2px;
}
.message.own {
justify-content: flex-end;
}
.message.other {
justify-content: flex-start;
}
.message-bubble {
max-width: 65%;
padding: 0.5rem 0.75rem;
border-radius: 8px;
position: relative;
}
.message.own .message-bubble {
background: #005c4b;
border-top-right-radius: 0;
}
.message.other .message-bubble {
background: #202c33;
border-top-left-radius: 0;
}
.message-sender {
display: block;
font-size: 0.75rem;
color: #00a884;
font-weight: 600;
margin-bottom: 2px;
}
.message-text {
font-size: 0.9rem;
line-height: 1.4;
margin-right: 3rem;
}
.message-time {
font-size: 0.7rem;
color: #8696a0;
float: right;
margin-top: 4px;
}
.typing-indicator {
font-size: 0.8rem;
color: #8696a0;
font-style: italic;
padding: 0.25rem 0;
}
.input-bar {
display: flex;
padding: 0.75rem 1rem;
background: #202c33;
gap: 0.5rem;
}
.input-bar input {
flex: 1;
padding: 0.75rem 1rem;
border: none;
border-radius: 8px;
background: #2a3942;
color: #e9edef;
font-size: 0.95rem;
outline: none;
}
.input-bar button {
padding: 0.75rem 1.5rem;
background: #00a884;
color: white;
border: none;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
}
.input-bar button:hover {
background: #008f72;
}
Implement the typing indicator
The typing indicator code is already in your component. Let's trace through how the debounce pattern works and verify the full flow end to end.
How does the debounce pattern work?
Every keystroke in the input field calls handleInputChange. This immediately sends a "typing: true" message to the server so other users see the indicator right away.
At the same time, it starts a 1-second countdown timer. If the user types another character before that second is up, the old timer is cleared and a fresh one starts. Only when the user pauses for a full second does the timer fire and send "typing: false".
Without debouncing, you would flood the server with a "stopped typing" message after every single keystroke pause. The 1000ms threshold means the server only gets notified when the user has genuinely stopped composing.
- Make sure your WebSocket server is still running in its terminal (the one showing WebSocket server running on ws://localhost:8080).
- Open two separate browser tabs pointing to your Vite dev server URL.
- In the first tab, type a username (for example, Alice) and click Join Chat.
- In the second tab, join with a different username (for example, Bob).
- Start typing a message in one tab.
In the other tab, you should see a "typing..." indicator appear below the message area. It disappears about one second after you stop typing.
Not seeing the typing indicator?
- Check that your WebSocket server terminal is still active. If you closed it, restart with npx tsx src/index.ts from inside the server directory.
- Make sure you are typing in a DIFFERENT tab from the one you are watching. Typing indicators are only shown to other users, not to yourself.
- Open the browser DevTools console. Look for WebSocket connection errors or JSON parse failures.
My typing indicator is not showing up between browser tabs. Help me debug.
Your chat UI is fully wired up. In the next step, you will test the complete message flow and handle edge cases like disconnections.
Connect and Test
Your chat UI is fully built and styled. The server knows how to handle connections, broadcast messages, and track typing status.
Now it's time to bring everything together. You'll start both servers and open the app in two browser tabs to simulate a real conversation between two users.
In this step, get ready to:
- Start the WebSocket server and the React dev server in separate terminals.
- Test real-time messaging between two browser tabs.
- Verify the connection lifecycle by disconnecting and reconnecting.
Start both servers
Your app has two halves that need to run simultaneously. The WebSocket server handles message routing, and the Vite dev server serves the React frontend.
- In VS Code, open a new terminal by pressing Ctrl+` (macOS and Windows).
- Navigate to your server directory and start the WebSocket server by running these commands:
cd server
npx tsx src/index.ts
You should see WebSocket server running on ws://localhost:8080 printed in the terminal.
What does this command do?
The npx tsx command runs your TypeScript file directly without needing a separate compile step. It uses esbuild under the hood to transpile TypeScript on the fly.
- Open a second terminal by pressing Ctrl+` again. VS Code creates a new terminal tab alongside the first.
- Navigate to your client directory and start the Vite dev server by running these commands:
cd client
npm run dev
Vite will display a local URL (typically http://localhost:5173) in the terminal output.
Server not starting?
If you see an error about the port already being in use, another process is occupying port 8080. Close any other running servers or restart your terminal.
If you see a module-not-found error, make sure you ran npm install inside the server directory in the previous step.
Help me debug my server startup error.
Test real-time messaging between two users
To simulate a real chat, you need two separate users connected at the same time. Opening two browser tabs gives you two independent WebSocket connections to the server.
- Open your browser and navigate to the URL Vite displayed (typically http://localhost:5173).
- You should see the join screen with a WebSocket Chat heading and a username input field.
- Type a username (for example, Alice) and click Join Chat.
- Open a second browser tab and navigate to the same URL.
- Enter a different username (for example, Bob) and click Join Chat.
Both tabs should now show the chat interface with a sidebar listing both usernames with green dots.
- In the first tab (Alice), type a message in the input bar and press Enter or click Send.
- Switch to the second tab (Bob). The message should appear instantly as a white bubble on the left side with Alice's name and a timestamp.
- Send a reply from Bob's tab. Switch back to Alice's tab to confirm it appears as a white bubble on the left.
- Verify that your own messages appear as green bubbles on the right side in each tab.
Why are the bubble colours different?
The CSS class .message.own positions your messages on the right with a green background. The class .message.other positions everyone else's messages on the left with a dark grey background. This is the same pattern WhatsApp uses to distinguish your messages from others.
- Test typing indicators by starting to type in one tab without sending. Switch to the other tab and look below the messages for an italic line saying the other user is typing.
- Stop typing and wait about one second. The typing indicator should disappear in the other tab.
- Check the sidebar in both tabs. You should see both usernames listed with green online dots. Your own name appears with (you) next to it.
Messages not appearing in the other tab?
Make sure both terminals are still running. If the server terminal shows an error, restart it with npx tsx src/index.ts.
Check your browser's developer console (press F12) for WebSocket connection errors. A message like WebSocket connection to 'ws://localhost:8080' failed means the server is not running.
My messages are not showing up in the other browser tab.
Test disconnection and reconnection
A real chat app needs to handle users leaving gracefully. The server's close event handler removes disconnected users from the clients Map and broadcasts an updated user list to everyone else.
- Close one of the two browser tabs (for example, Bob's tab).
- Switch to the remaining tab (Alice's). The sidebar should now show only one user listed.
- The online count in the sidebar heading should update from Online (2) to Online (1).
What happens on disconnect?
When a browser tab closes, the browser terminates the WebSocket connection. The server's ws.on("close") handler fires, which deletes the client from the Map and calls broadcastUserList() to notify all remaining clients.
This is the WebSocket connection lifecycle in action: open, message, close. Every WebSocket application manages these three events.
- Open a new browser tab and navigate to the same URL again.
- Enter a username (you can reuse Bob or try a new one) and click Join Chat.
- Verify that the sidebar in both tabs updates to show two users online again.
- Send a message from the newly joined tab to confirm messaging works after reconnection.
User not disappearing from the list?
If you closed the tab but the user still appears in the sidebar, the server may not have detected the disconnection yet. WebSocket close events are usually instantaneous, but some browsers delay them slightly. Wait a few seconds and the list should update.
If the user still persists, check your server's ws.on("close") handler in server/src/index.ts. It should call clients.delete(clientId) followed by broadcastUserList().
The user list is not updating when someone disconnects.
Secret mission
Add Read Receipts
Ever noticed those blue checkmarks in WhatsApp? They tell you exactly when someone has read your message. In this mission, you'll implement the same feature: single grey check for sent, double grey for delivered, and double blue for read.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your project files, pause the running servers, or delete everything entirely.
Resources you used:
- The server folder containing your WebSocket server code and node_modules.
- The client folder containing your React frontend code and node_modules.
- Two running terminal processes (WebSocket server on port 8080, Vite dev server on port 5173).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your chat app.
- Your WebSocket server continues running on ws://localhost:8080.
- Your Vite dev server continues running on http://localhost:5173.
- Both processes will stop if you close the terminal windows or restart your computer.
Pause - I'll come back to this later
Stop the running processes to free up terminal sessions and ports, but keep your project files for later.
- Switch to the terminal running the WebSocket server and press Ctrl+C to stop it.
- Switch to the terminal running the Vite dev server and press Ctrl+C to stop it.
- Your server and client folders stay on your Desktop. To resume later, open two terminals and start each server again.
Delete - I don't want to use this again
Remove all project files and free up disk space. This cannot be undone.
- Stop both running servers by pressing Ctrl+C in each terminal.
- Delete the server and client folders by running these commands:
cd ~/Desktop
rm -rf server client
On Windows, run this instead:
cd $HOME\Desktop
Remove-Item -Recurse -Force server, client
- Confirm the folders are gone by running:
ls
You should no longer see server or client in the output.
That's a Wrap!
That's a Wrap!
Nice work. You just built a real-time chat application from scratch using WebSockets and TypeScript.
You've learned how to:
- Build a real-time WebSocket server in TypeScript using the ws library, managing connections, broadcasting messages, and tracking online users.
- Define a typed message protocol shared between client and server, enforcing type-safe communication across the entire stack.
- Create a WhatsApp-style React UI with chat bubbles, timestamps, an online users sidebar, and typing indicators with debouncing.
- Secret Mission: Added read receipts with delivery state tracking (sent, delivered, read) using checkmark icons that turn blue when the recipient has seen the message.
Ready to quiz yourself?