Implementing Live Chat on a Website
Live chat is not just WebSocket with text. A complete chat includes message history, typing indicator, read statuses, file support, and optimistic UI updates. Each of these elements requires separate solutions.
Data Structure
interface ChatRoom {
id: string;
type: 'direct' | 'group' | 'support';
participants: string[]; // userIds
name?: string; // for groups
lastMessage?: Message;
unreadCount: number;
}
interface Message {
id: string;
roomId: string;
senderId: string;
type: 'text' | 'image' | 'file' | 'system';
content: string;
attachments?: Attachment[];
replyTo?: string; // id of parent message
editedAt?: Date;
deletedAt?: Date;
status: 'sending' | 'sent' | 'delivered' | 'read';
createdAt: Date;
}
interface Attachment {
id: string;
type: 'image' | 'file';
url: string;
name: string;
size: number;
mimeType: string;
}
Server Side: Socket.IO
// server/chat.ts
import { Server, Socket } from 'socket.io';
import { db } from './db';
import { redisAdapter } from '@socket.io/redis-adapter';
export function initChat(io: Server) {
io.on('connection', async (socket: Socket) => {
const userId = socket.data.userId;
// Join all user's rooms on connection
const rooms = await db.chatRoom.findMany({
where: { participants: { has: userId } },
select: { id: true },
});
rooms.forEach(({ id }) => socket.join(`room:${id}`));
// Send message
socket.on('message:send', async (payload: {
roomId: string;
content: string;
type: 'text' | 'image' | 'file';
replyTo?: string;
clientId: string; // temp id for optimistic update
}, ack) => {
// Check access
const room = await db.chatRoom.findFirst({
where: { id: payload.roomId, participants: { has: userId } },
});
if (!room) return ack({ error: 'Access denied' });
const message = await db.message.create({
data: {
roomId: payload.roomId,
senderId: userId,
type: payload.type,
content: payload.content,
replyToId: payload.replyTo,
status: 'sent',
},
});
// Broadcast to room
io.to(`room:${payload.roomId}`).emit('message:new', message);
// ACK to sender with server id
ack({ ok: true, message, clientId: payload.clientId });
});
// Typing indicator
socket.on('typing:start', ({ roomId }) => {
socket.to(`room:${roomId}`).emit('typing:update', {
userId,
roomId,
isTyping: true,
});
});
socket.on('typing:stop', ({ roomId }) => {
socket.to(`room:${roomId}`).emit('typing:update', {
userId,
roomId,
isTyping: false,
});
});
// Mark messages as read
socket.on('messages:read', async ({ roomId, upToMessageId }) => {
await db.messageRead.upsert({
where: { userId_roomId: { userId, roomId } },
update: { lastReadMessageId: upToMessageId, readAt: new Date() },
create: { userId, roomId, lastReadMessageId: upToMessageId, readAt: new Date() },
});
socket.to(`room:${roomId}`).emit('messages:read:update', {
userId,
roomId,
upToMessageId,
});
});
// Message history (cursor pagination)
socket.on('messages:load', async ({ roomId, before, limit = 50 }, ack) => {
const messages = await db.message.findMany({
where: {
roomId,
...(before ? { createdAt: { lt: new Date(before) } } : {}),
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
take: limit + 1,
include: { sender: { select: { id: true, name: true, avatar: true } } },
});
ack({
messages: messages.slice(0, limit).reverse(),
hasMore: messages.length > limit,
nextCursor: messages.length > limit
? messages[limit - 1].createdAt.toISOString()
: null,
});
});
});
}
Optimistic Updates
Message appears instantly without waiting for server. On ACK reception — replaced with real object:
// store/chat.ts (Zustand)
interface ChatStore {
messages: Map<string, Message[]>;
pendingIds: Map<string, string>; // clientId -> roomId
addOptimistic: (roomId: string, content: string) => string;
confirmMessage: (clientId: string, serverMessage: Message) => void;
failMessage: (clientId: string) => void;
}
const useChatStore = create<ChatStore>((set, get) => ({
messages: new Map(),
pendingIds: new Map(),
addOptimistic(roomId, content) {
const clientId = `pending-${Date.now()}-${Math.random()}`;
const optimistic: Message = {
id: clientId,
roomId,
senderId: currentUserId,
type: 'text',
content,
status: 'sending',
createdAt: new Date(),
};
set((s) => {
const msgs = [...(s.messages.get(roomId) ?? []), optimistic];
s.messages.set(roomId, msgs);
s.pendingIds.set(clientId, roomId);
return { messages: new Map(s.messages) };
});
return clientId;
},
confirmMessage(clientId, serverMessage) {
set((s) => {
const roomId = s.pendingIds.get(clientId)!;
const msgs = s.messages.get(roomId) ?? [];
const idx = msgs.findIndex((m) => m.id === clientId);
if (idx !== -1) msgs[idx] = { ...serverMessage, status: 'sent' };
s.pendingIds.delete(clientId);
return { messages: new Map(s.messages) };
});
},
}));
// Send with optimistic update
async function sendMessage(roomId: string, content: string) {
const clientId = useChatStore.getState().addOptimistic(roomId, content);
socket.emit('message:send', { roomId, content, type: 'text', clientId },
(response: { ok: boolean; message?: Message; clientId: string }) => {
if (response.ok) {
useChatStore.getState().confirmMessage(clientId, response.message!);
} else {
useChatStore.getState().failMessage(clientId);
}
}
);
}
Typing Indicator: Debounce
// In input component
const typingTimeout = useRef<ReturnType<typeof setTimeout>>();
function handleInput(value: string) {
setDraft(value);
socket.emit('typing:start', { roomId });
clearTimeout(typingTimeout.current);
typingTimeout.current = setTimeout(() => {
socket.emit('typing:stop', { roomId });
}, 2000);
}
// Display
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
socket.on('typing:update', ({ userId, isTyping }) => {
setTypingUsers((prev) => {
const next = new Set(prev);
isTyping ? next.add(userId) : next.delete(userId);
return next;
});
});
// UI
{typingUsers.size > 0 && (
<div className="typing-indicator">
<span>{getUserNames(typingUsers)} is typing...</span>
<BouncingDots />
</div>
)}
File Upload
Files don't go through WebSocket — upload to S3/MinIO first, then pass URL in message:
async function sendFile(roomId: string, file: File) {
// Upload via presigned URL
const { uploadUrl, fileUrl } = await api.post('/chat/upload-url', {
filename: file.name,
mimeType: file.type,
size: file.size,
});
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
const clientId = useChatStore.getState().addOptimistic(roomId, file.name);
socket.emit('message:send', {
roomId,
type: 'file',
content: file.name,
clientId,
attachment: { url: fileUrl, name: file.name, size: file.size, mimeType: file.type },
}, (response) => {
if (response.ok) {
useChatStore.getState().confirmMessage(clientId, response.message!);
}
});
}
Push Notifications for Background Tabs
When user is not on the chat page, new messages are delivered via Web Push:
// service-worker.ts
self.addEventListener('push', (event: PushEvent) => {
const data = event.data?.json();
event.waitUntil(
self.registration.showNotification(data.senderName, {
body: data.content,
icon: data.senderAvatar,
badge: '/badge.png',
data: { roomId: data.roomId, url: `/chat/${data.roomId}` },
})
);
});
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Basic chat (text, history, presence): 5–7 days. Full implementation with files, push notifications, read receipts and search: 2–3 weeks.







