Building a Real-Time Chat Application with Socket.IO and Express
Introduction
Real-time communication has become essential in modern web applications. Whether you're building a customer support system, team collaboration tool, or social platform, implementing real-time features can significantly enhance user experience. In this comprehensive guide, we'll build a production-ready chat application using Socket.IO and Express, complete with user authentication, room management, and message persistence.
Project Setup and Dependencies
Let's start by setting up our project structure and installing the necessary dependencies:
mkdir realtime-chat-app
cd realtime-chat-app
npm init -y
npm install express socket.io cors dotenv bcryptjs jsonwebtoken mongoose
npm install -D nodemonCreate the following project structure:
├── server.js
├── models/
│ ├── User.js
│ └── Message.js
├── middleware/
│ └── auth.js
├── routes/
│ └── auth.js
└── public/
├── index.html
├── style.css
└── client.jsSetting Up the Express Server with Socket.IO
First, let's create our main server file with Socket.IO integration:
// server.js
const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const cors = require('cors');
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
require('dotenv').config();
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: process.env.CLIENT_URL || "http://localhost:3000",
methods: ["GET", "POST"]
}
});
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// Database connection
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost/chatapp', {
useNewUrlParser: true,
useUnifiedTopology: true
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});User Authentication and Models
Let's create our user and message models for MongoDB:
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true
},
password: {
type: String,
required: true,
minlength: 6
},
isOnline: {
type: Boolean,
default: false
},
lastSeen: {
type: Date,
default: Date.now
}
}, { timestamps: true });
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
userSchema.methods.comparePassword = async function(password) {
return bcrypt.compare(password, this.password);
};
module.exports = mongoose.model('User', userSchema);// models/Message.js
const mongoose = require('mongoose');
const messageSchema = new mongoose.Schema({
content: {
type: String,
required: true,
trim: true
},
sender: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
room: {
type: String,
required: true
},
messageType: {
type: String,
enum: ['text', 'image', 'file', 'system'],
default: 'text'
},
edited: {
type: Boolean,
default: false
},
editedAt: Date
}, { timestamps: true });
module.exports = mongoose.model('Message', messageSchema);Implementing Socket.IO Real-Time Features
Now let's implement the core real-time functionality with Socket.IO:
// Add this to server.js after the database connection
const User = require('./models/User');
const Message = require('./models/Message');
// Socket authentication middleware
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
throw new Error('No token provided');
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.id);
if (!user) {
throw new Error('User not found');
}
socket.user = user;
next();
} catch (error) {
next(new Error('Authentication failed'));
}
});
// Socket connection handling
io.on('connection', async (socket) => {
console.log(`User ${socket.user.username} connected`);
// Update user online status
await User.findByIdAndUpdate(socket.user._id, {
isOnline: true,
lastSeen: new Date()
});
// Join user to their rooms
socket.on('join-room', async (roomName) => {
socket.join(roomName);
// Load and send message history
const messages = await Message.find({ room: roomName })
.populate('sender', 'username')
.sort({ createdAt: -1 })
.limit(50);
socket.emit('message-history', messages.reverse());
// Notify others in the room
socket.to(roomName).emit('user-joined', {
username: socket.user.username,
message: `${socket.user.username} joined the room`
});
});
// Handle new messages
socket.on('send-message', async (data) => {
try {
const message = new Message({
content: data.content,
sender: socket.user._id,
room: data.room,
messageType: data.messageType || 'text'
});
await message.save();
await message.populate('sender', 'username');
// Broadcast message to room
io.to(data.room).emit('new-message', {
_id: message._id,
content: message.content,
sender: message.sender,
room: message.room,
messageType: message.messageType,
createdAt: message.createdAt
});
} catch (error) {
socket.emit('error', { message: 'Failed to send message' });
}
});
// Handle typing indicators
socket.on('typing', (data) => {
socket.to(data.room).emit('user-typing', {
username: socket.user.username,
isTyping: data.isTyping
});
});
// Handle disconnection
socket.on('disconnect', async () => {
console.log(`User ${socket.user.username} disconnected`);
// Update user offline status
await User.findByIdAndUpdate(socket.user._id, {
isOnline: false,
lastSeen: new Date()
});
// Notify all rooms about user going offline
socket.broadcast.emit('user-offline', {
username: socket.user.username
});
});
});Client-Side Implementation
Here's a basic client implementation to test our chat functionality:
// public/client.js
const socket = io({
auth: {
token: localStorage.getItem('token')
}
});
const messagesContainer = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const sendButton = document.getElementById('sendButton');
const currentRoom = 'general';
// Join the default room
socket.emit('join-room', currentRoom);
// Listen for new messages
socket.on('new-message', (message) => {
displayMessage(message);
});
// Listen for message history
socket.on('message-history', (messages) => {
messages.forEach(displayMessage);
});
// Send message function
function sendMessage() {
const content = messageInput.value.trim();
if (content) {
socket.emit('send-message', {
content: content,
room: currentRoom
});
messageInput.value = '';
}
}
// Display message function
function displayMessage(message) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message';
messageDiv.innerHTML = `
${message.sender.username}:
${message.content}
${new Date(message.createdAt).toLocaleTimeString()}
`;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// Event listeners
sendButton.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
sendMessage();
}
});Advanced Features and Optimization
To make your chat application production-ready, consider implementing these additional features:
- Rate Limiting: Prevent spam by limiting messages per user per minute
- Message Encryption: Encrypt sensitive messages before storing
- File Uploads: Support image and file sharing with proper validation
- Push Notifications: Notify offline users about new messages
- Redis Adapter: Scale Socket.IO across multiple server instances
For rate limiting, you can add this middleware:
const rateLimit = new Map();
socket.on('send-message', async (data) => {
const userId = socket.user._id.toString();
const now = Date.now();
const userLimit = rateLimit.get(userId) || { count: 0, resetTime: now + 60000 };
if (now > userLimit.resetTime) {
userLimit.count = 0;
userLimit.resetTime = now + 60000;
}
if (userLimit.count >= 10) {
socket.emit('error', { message: 'Rate limit exceeded' });
return;
}
userLimit.count++;
rateLimit.set(userId, userLimit);
// Continue with message processing...
});Conclusion
We've built a comprehensive real-time chat application with user authentication, room management, and message persistence. This foundation can be extended with features like private messaging, file sharing, and advanced moderation tools. Remember to implement proper error handling, input validation, and security measures before deploying to production. The combination of Socket.IO and Express provides a robust platform for building scalable real-time applications.
Related Posts
Implementing Clean Architecture with NestJS: Building Scalable Enterprise Applications
Learn how to implement Clean Architecture principles in NestJS applications for better maintainability and testability.
Implementing Custom JWT Authentication in NestJS: A Production-Ready Guide
Build secure, scalable JWT authentication in NestJS with refresh tokens, role-based access control, and best security practices.
Building Production-Ready APIs with NestJS: A Complete Guide to Scalable Backend Architecture
Master NestJS fundamentals and build enterprise-grade APIs with proper architecture, validation, and error handling.