BUILD A REAL-TIME CHAT APPLICATION
WebSockets unlock a class of applications that plain HTTP cannot handle well. Anything where the server needs to push data to clients without waiting for a request falls into this category. Chat is the canonical example, but the same pattern applies to live dashboards, collaborative editors, and multiplayer games.
In this tutorial we'll build a chat app with rooms, typing indicators, and message history. Every part of the architecture is explained so you can adapt it for your own projects.
WHAT YOU'LL BUILD
- A Node.js WebSocket server using the ws library
- A React frontend with a custom hook that encapsulates all socket logic
- Room-based messaging so users can switch channels
- Typing indicators that disappear after a short idle timeout
- In-memory message history sent to new joiners on connect
PREREQUISITES
- Comfortable with React hooks (useState, useEffect, useRef, useCallback)
- Comfortable with Node.js and npm
- Node 18 or higher installed
STEP 1: PROJECT STRUCTURE
We'll use a monorepo-style layout with two packages — server and client. The server folder holds the WebSocket backend, and the client folder holds the React app. Inside the server, you'll have a single entry point at src/index.ts. Inside the client, you'll have App.tsx, a hooks folder with useChat.ts, and a components folder with MessageList, MessageInput, and RoomList.
Start by creating the directories and initializing both packages separately with npm init.
STEP 2: WEBSOCKET SERVER
The server uses the ws package, which gives you a raw WebSocket server without the overhead of a full framework. Install it along with TypeScript support using:
npm install ws
npm install -D typescript @types/ws @types/node ts-node
The first thing to define is a shared message envelope — a TypeScript interface that both client and server agree on. Every message has a type field (join, leave, message, typing, stop_typing, history, or user_list), a room, a userId, a username, an optional text body, and a timestamp. The history and user_list types also carry arrays of messages and usernames respectively.
The server maintains three in-memory Maps: one mapping room names to sets of connected WebSocket clients, one mapping rooms to their last 50 messages, and one mapping each WebSocket connection to its user info. When a client connects and sends a join message, the server removes them from their previous room if any, adds them to the new room, sends them the room's message history, and broadcasts the updated user list to everyone else in the room.
Sending a message is straightforward: the server appends it to the room's history (trimming to 50 entries if needed) and broadcasts it to all room members. Typing and stop_typing events are broadcast to everyone except the sender, so you don't see your own typing indicator.
On disconnect, the server removes the client from the room and broadcasts a leave event with the updated user list.
The full server fits in about 80 lines of TypeScript. Start it with npx ts-node src/index.ts and it listens on port 8080.
STEP 3: THE useChat HOOK
Keeping all socket logic in a single React hook makes the UI components simple. The hook accepts a userId, username, and room and returns messages, typingUsers, onlineUsers, a connected boolean, and two functions: sendMessage and sendTyping.
Inside the hook, a useEffect opens a WebSocket connection and sends a join message as soon as it's open. The onmessage handler routes incoming messages by type — history sets the initial message array, message appends to it and clears that user's typing indicator, typing and stop_typing update a Set of currently-typing usernames, and user_list and leave update the online users list. The effect returns a cleanup function that closes the socket.
The sendTyping function is worth special attention. Each time it's called, it sends a typing event and also schedules a stop_typing event 2 seconds later (clearing the previous timer if one was pending). This means typing indicators vanish automatically if the user stops typing, without any explicit "stopped typing" action from the UI.
STEP 4: UI COMPONENTS
The MessageInput component is a controlled form input. On every keystroke it calls onTyping (which triggers the hook's sendTyping logic) and on submit it calls onSend with the trimmed text.
The MessageList component maps over the messages array and renders each one with a different alignment depending on whether the userId matches the current user. Your own messages go right, everyone else's go left. At the bottom, if typingUsers has any entries, a small italic line shows who is typing. The component also uses a ref on the bottom of the list to auto-scroll whenever messages or typingUsers change.
The App component ties everything together. It has a simple join screen that asks for a username, then renders the full chat layout once joined. The layout has a sidebar with room links and an online user list, and a main area with the message list and input. Switching rooms just updates the room state variable, which causes the hook to reconnect.
STEP 5: HANDLING EDGE CASES
Reconnection is something the basic setup doesn't handle — if the server restarts or the connection drops, the socket just closes. For production, implement exponential backoff reconnection. Each failed connection attempt schedules a retry with a delay that doubles each time (1s, 2s, 4s, up to a cap of 30 seconds). Pass the attempt count as a parameter to a connect function that recursively calls itself in the onclose handler.
Message deduplication is also worth thinking about. Assign a UUID to each outgoing message on the client and track received IDs in a Set. If the same message ID arrives twice (which can happen on flaky connections), skip the second one.
For persistence beyond in-memory storage, swap the history Map for a Redis list. Use LPUSH to prepend new messages and LRANGE to fetch the last 50. Redis keeps data alive across server restarts without adding much complexity.
STEP 6: RUNNING THE APP
Open two terminal windows. In the first, run the server with npx ts-node src/index.ts. In the second, bootstrap the React app with Vite using npm create vite@latest and run npm run dev. Open two browser tabs, pick different names, join the same room, and watch the messages arrive in real time.
WHAT TO BUILD NEXT
- Scale horizontally by adding a Redis pub/sub adapter so multiple server processes share the same rooms
- Add authentication with JWTs validated on the WebSocket upgrade event before the handshake completes
- Persist messages to a database — PostgreSQL with Prisma is a natural next step
- Deploy the server as a Cloudflare Worker using the Durable Objects API, where each room becomes its own Durable Object instance
Back to DIY Tutorials
DIY TutorialIntermediate
Build a Real-Time Chat Application
Learn how to build a fully functional real-time chat app with WebSockets, React, and Node.js from scratch.
January 15, 202445 min read
WebSocketReactNode.jsReal-time