Skip to main content

Building a Live Chat App With WebSockets

A live chat app combines all the patterns from previous articles: WebSocket connections, reconnection logic, presence tracking, and optimistic updates. Users send messages and see them appear instantly; typing indicators show who's writing; presence shows who's online. This article pulls it all together into a production-ready chat component (Crater Labs, 2026).

Chat App Architecture​

A chat system has these parts:

  1. Message storage: Database of messages with ID, sender, timestamp, content.
  2. WebSocket handler: Broadcasts new messages to all clients in a room.
  3. Presence tracking: Shows who's online (from article 6).
  4. Typing indicators: Broadcasts when user starts/stops typing.
  5. Message persistence: Fetch message history on room join.
  6. Optimistic updates: Show user's own message before confirmation.

Complete Chat Component​

Here's a production-grade chat implementation:

import React, { useState, useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useWebSocket } from './useWebSocket'; // Article 4
import { usePresence } from './usePresence'; // Article 6

export function LiveChatRoom({ roomId, userId, username }) {
const queryClient = useQueryClient();
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [remoteTyingUsers, setRemoteTypingUsers] = useState(new Set());
const messagesEndRef = useRef(null);
const typingTimeoutRef = useRef(null);

// Fetch message history
const { data: messages = [] } = useQuery({
queryKey: ['messages', roomId],
queryFn: async () => {
const res = await fetch(`/api/rooms/${roomId}/messages`);
return res.json();
},
});

// Get presence (who's online)
const presentUsers = usePresence(userId, username, roomId);

// WebSocket connection
const { connected, send } = useWebSocket(
`wss://your-server.example.com/rooms/${roomId}`,
{
onMessage: (msg) => {
if (msg.type === 'message') {
// Add new message to cache
queryClient.setQueryData(['messages', roomId], (oldMessages) => [
...(oldMessages || []),
{
id: msg.id,
userId: msg.userId,
username: msg.username,
text: msg.text,
timestamp: msg.timestamp,
pending: false,
},
]);
}

if (msg.type === 'typing-start') {
setRemoteTypingUsers((prev) => new Set([...prev, msg.userId]));
}

if (msg.type === 'typing-stop') {
setRemoteTypingUsers((prev) => {
const next = new Set(prev);
next.delete(msg.userId);
return next;
});
}
},
}
);

// Auto-scroll to latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

// Handle typing with debounce
const handleInputChange = (e) => {
const value = e.target.value;
setInput(value);

if (!isTyping) {
setIsTyping(true);
send({ type: 'typing-start', userId, username, roomId });
}

// Clear timeout and set new one
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}

typingTimeoutRef.current = setTimeout(() => {
setIsTyping(false);
send({ type: 'typing-stop', userId, roomId });
}, 1000);
};

const handleSend = () => {
if (!input.trim() || !connected) return;

const messageId = Date.now();

// Optimistic update: add to cache immediately
queryClient.setQueryData(['messages', roomId], (oldMessages) => [
...(oldMessages || []),
{
id: messageId,
userId,
username,
text: input,
timestamp: Date.now(),
pending: true,
},
]);

// Send to server
send({
type: 'message',
userId,
username,
text: input,
roomId,
});

// Stop typing indicator
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
setIsTyping(false);
setInput('');
send({ type: 'typing-stop', userId, roomId });
};

const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};

return (
`<div style={{ display: 'flex', height: '100vh' }}>`
{/* Sidebar: Online users */}
`<div style={{ width: '250px', borderRight: '1px solid #ccc', padding: '16px' }}>`
`<h3>`Online ({presentUsers.length})`</h3>`
{presentUsers.map((user) => (
`<div key={user.userId} style={{ marginBottom: '8px' }}>`
`<span`
style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#4caf50',
marginRight: '8px',
}}
`/>`
{user.username}
`</div>`
))}
`</div>`

{/* Main chat area */}
`<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>`
{/* Messages */}
`<div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>`
{messages.map((msg) => (
`<div key={msg.id} style={{ marginBottom: '12px', opacity: msg.pending ? 0.5 : 1 }}>`
`<strong>`{msg.username}:`</strong>
` {msg.text}`
` `
`<span style={{ fontSize: '0.8em', color: '#666' }}>`
{new Date(msg.timestamp).toLocaleTimeString()}
`</span>`
`</div>`
))}
{remoteTyingUsers.size > 0 && (
`<div style={{ fontStyle: 'italic', color: '#999' }}>`
{Array.from(remoteTyingUsers)
.map((userId) => presentUsers.find((u) => u.userId === userId)?.username || 'User')
.join(', ')} {remoteTyingUsers.size === 1 ? 'is' : 'are'} typing...
`</div>`
)}
`<div ref={messagesEndRef} />`
`</div>`

{/* Input */}
`<div style={{ borderTop: '1px solid #ccc', padding: '16px', display: 'flex', gap: '8px' }}>`
`<textarea`
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type a message (Shift+Enter for newline)"
style={{ flex: 1, resize: 'none' }}
rows="3"
`/>`
`<button onClick={handleSend} disabled={!connected}>`
Send
`</button>`
`</div>`

{!connected && `<div style={{ padding: '8px', backgroundColor: '#fff3cd', color: '#856404' }}>`
Reconnecting...
`</div>`}
`</div>`
`</div>`
);
}

Server-Side Chat Handler​

A minimal chat server using Node.js and the ws library:

import WebSocket from 'ws';
import http from 'http';
import { randomUUID } from 'crypto';

const server = http.createServer();
const wss = new WebSocket.Server({ server });

const rooms = new Map();

wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const roomId = url.pathname.split('/').pop();

if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}

const room = rooms.get(roomId);
room.add(ws);

ws.on('message', (data) => {
try {
const msg = JSON.parse(data);

if (msg.type === 'message') {
// Store message (in a real app, save to database)
const dbMessage = {
id: randomUUID(),
...msg,
timestamp: Date.now(),
};

// Broadcast to all in room
room.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(
JSON.stringify({
type: 'message',
...dbMessage,
})
);
}
});
}

if (msg.type === 'typing-start' || msg.type === 'typing-stop') {
// Broadcast typing indicator
room.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(msg));
}
});
}
} catch (error) {
console.error('Parse error:', error);
}
});

ws.on('close', () => {
room.delete(ws);
if (room.size === 0) {
rooms.delete(roomId);
}
});
});

server.listen(3000);

Performance Considerations​

For a chat app with 1000+ users, optimize:

  1. Message pagination: Load older messages on scroll-up, not all at once.
  2. Typing debounce: Only broadcast typing after 500ms of inactivity (not on every keystroke).
  3. Message batching: If 50 messages arrive in 100ms, render as a batch, not 50 separate renders.
  4. Virtual scrolling: For large message lists, render only visible messages using react-window.

Example: Pagination with infinite scroll:

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['messages', roomId],
queryFn: async ({ pageParam = 0 }) => {
const res = await fetch(
`/api/rooms/${roomId}/messages?limit=50&offset=${pageParam * 50}`
);
return res.json();
},
getNextPageParam: (lastPage, pages) => {
return lastPage.length === 50 ? pages.length : null;
},
});

Key Takeaways​

  • A live chat app combines WebSocket connections, presence tracking, and optimistic updates.
  • Send typing indicators with debouncing to avoid message spam.
  • Use optimistic updates so users see their messages instantly, before server confirmation.
  • Paginate message history; don't load all messages on join.
  • Broadcast presence so users see who's online in the room.

Frequently Asked Questions​

Should I store message attachments (images, files)?​

Store file URLs in the message object. Upload files to cloud storage (S3, GCS) separately using REST, then reference the URL in the WebSocket message.

How do I handle message edits and deletions?​

Add type: 'message-edit' and type: 'message-delete' messages. Update or remove from the cache using queryClient.setQueryData().

How do I implement read receipts?​

When a message is read (scrolled into view), send a type: 'read-receipt' with the message ID. Store in cache and render a checkmark next to the message.

Can I have multiple chat rooms?​

Yes, separate by roomId in the URL and query key. Each room has its own WebSocket connection, or multiplex over one connection using message routing.

Further Reading​