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