Skip to content
Created: April 27, 2023

Implementation function:

  • Single person private chat (1:1)
  • Group chat (rooms/channels)
  • In group chat @某人 (mentions) and generate reminders
  • Chat records (persistence, paging, retrieval)
  • Online/offline message processing and offline push (overview)
  • Security, scaling and performance recommendations

Design ideas → Data model → WebSocket event protocol → Backend (Node.js + Socket.IO + MongoDB) complete example → Frontend (HTML + JS + socket.io-client) example → Advanced and deployment concerns

design ideas ​

  1. Transport layer: Use WebSocket (socket.io is recommended to simplify room/reconnection/heartbeat/cross-domain, etc.), or native ws.

  2. Authentication: Return JWT (or session) after logging in, and use token verification when connecting to WebSocket.

  3. Room Model:

    • Private chat: Create a "private chat room id" for each pair of users, such as dm:<userAId>:<userBId> (ids can be sorted from small to large to avoid duplication).
    • Group chat: use room:<roomId>.
  4. Message persistence: The message is written to DB (MongoDB recommended, document type is suitable for chatting), and the mentions field array (the user id marked by @) is recorded.

  5. @ Implementation: The client completes the username list (or uid) in real time when inputting it. When sending a message, it puts mentions: [userId...] in the message body, and the server also performs a parsing check (anti-counterfeiting).

  6. Offline/Unread: Record which target users have not read (unread map) when writing messages, or maintain lastReadAt to facilitate statistics of unread and push.

  7. Message pulling/pagination: REST API uses GET /rooms/:id/messages?before=<timestamp|id>&limit=20 to paginate loading history.

  8. Notification: When the message contains someone's id (mentions), immediately push a mention event to the user (if online), or record unread in the DB and send mobile notifications in conjunction with Push Notification (APNs/FCM).

  9. Scale/Horizontal: Use Redis pub/sub or socket.io-adapter (redis-adapter) for multi-instance event broadcasting.

Data model (MongoDB/Mongoose style example) ​

js
// User
{
  _id: ObjectId,
  username: String, // 唯一
  displayName: String,
  avatarUrl: String,
  passwordHash: String,
  lastSeenAt: Date,
  createdAt: Date
}

// Room
{
  _id: ObjectId,
  type: "dm" | "group",
  members: [ObjectId],
  name?: String,       // 群名
  ownerId?: ObjectId,
  createdAt: Date,
  meta: {}
}

// Message
{
  _id: ObjectId,
  roomId: ObjectId,
  senderId: ObjectId,
  content: String,           // 原始文本(可存富文本或 markdown)
  contentHtml?: String,      // 可选:预渲染后的 html
  mentions: [ObjectId],      // 被 @ 的用户 id 列表
  attachments?: [{ url, type, filename }],
  createdAt: Date,
  editedAt?: Date,
  deleted?: Boolean,
  readBy?: [{ userId: ObjectId, at: Date }] // 或在另表维护 read 状态
}

WebSocket/Event Protocol (recommended) ​

Uniformly use JSON event object { type, payload } or socket.io’s event name method.The following is expressed using the socket.io event name.

Client events (client → server):

  • auth: Send token after connection (can be omitted if using socket.io’s auth option).
  • join_room { roomId }: Join the room (server verifies permissions).
  • leave_room { roomId }
  • send_message { roomId, content, mentions: [userId], attachments?: [...] }
  • typing { roomId, isTyping: true|false }
  • fetch_messages { roomId, beforeId?, limit? } (REST can also be used)

Server events (server → client):

  • message { message } (real-time delivery)
  • message_ack { tempId, messageId } (can be used for client echo confirmation)
  • typing { roomId, userId, isTyping }
  • mention { roomId, messageId, fromUserId } (special mention reminder)
  • presence { userId, online } or presence_update
  • history { messages: [...] } (in response to fetch_messages)
  • error { code, message }

Backend example (Node.js + Express + Socket.IO + Mongoose) ​

This teaching example includes password verification, HTTP/Socket authentication, membership checks and mention validation. Prepare user and room data first; production still needs rate limits, idempotency and connection/room permission revocation.

First npm init -y, install:

npm i express socket.io mongoose jsonwebtoken bcryptjs cors

Example server.js:

js
// server.js
const express = require('express');
const http = require('http');
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { Server } = require('socket.io');
const cors = require('cors');

const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) throw new Error('Set JWT_SECRET before starting the demo');
const MONGO = process.env.MONGO || 'mongodb://localhost:27017/chatapp';

const User = mongoose.model('User', new mongoose.Schema({
  username: { type: String, unique: true, required: true },
  displayName: String,
  passwordHash: { type: String, required: true }
}));
const Room = mongoose.model('Room', new mongoose.Schema({
  type: { type: String, enum: ['dm', 'group'] },
  members: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
  name: String
}));
const Message = mongoose.model('Message', new mongoose.Schema({
  roomId: { type: mongoose.Schema.Types.ObjectId, ref: 'Room' },
  senderId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  content: String,
  mentions: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
  createdAt: { type: Date, default: Date.now }
}));

const app = express();
app.use(cors());
app.use(express.json({ limit: '32kb' }));
const asyncRoute = handler => (req, res, next) =>
  Promise.resolve(handler(req, res, next)).catch(next);
function fail(status, message) {
  const error = new Error(message);
  error.status = status;
  throw error;
}
function validId(id) {
  return typeof id === 'string' && mongoose.isObjectIdOrHexString(id);
}
async function authenticate(token) {
  if (typeof token !== 'string' || !token) fail(401, 'authentication required');
  let payload;
  try {
    payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
  } catch {
    fail(401, 'invalid or expired token');
  }
  if (!validId(payload.userId) || !Number.isFinite(payload.exp)) fail(401, 'invalid token');
  const user = await User.findById(payload.userId);
  if (!user) fail(401, 'user not found');
  return { user, userId: String(user._id), expiresAt: payload.exp * 1000 };
}
async function requireRoom(roomId, userId) {
  if (!validId(roomId)) fail(400, 'invalid room id');
  const room = await Room.findById(roomId);
  if (!room) fail(404, 'room not found');
  if (!room.members.some(id => String(id) === userId)) fail(403, 'no permission');
  return room;
}

// Prepare users with bcrypt password hashes separately; this is not registration.
app.post('/login', asyncRoute(async (req, res) => {
  const { username, password } = req.body || {};
  if (typeof username !== 'string' || typeof password !== 'string') {
    fail(400, 'username and password required');
  }
  const user = await User.findOne({ username });
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    fail(401, 'invalid credentials');
  }
  const token = jwt.sign({ userId: String(user._id) }, JWT_SECRET,
    { algorithm: 'HS256', expiresIn: '1h' });
  res.json({ token, user: { _id: user._id, username: user.username } });
}));

// Express routes require their own authentication; io.use does not protect them.
app.get('/rooms/:roomId/messages', asyncRoute(async (req, res) => {
  const token = /^Bearer (.+)$/.exec(req.get('Authorization') || '')?.[1];
  const { userId } = await authenticate(token);
  const { roomId } = req.params;
  await requireRoom(roomId, userId);
  const requestedLimit = Number(req.query.limit ?? 20);
  if (!Number.isInteger(requestedLimit) || requestedLimit < 1) fail(400, 'invalid limit');
  const before = req.query.before;
  if (before !== undefined && !validId(before)) fail(400, 'invalid cursor');
  const query = { roomId };
  if (before) query._id = { $lt: before };
  const messages = await Message.find(query).sort({ _id: -1 })
    .limit(Math.min(50, requestedLimit)).lean();
  res.json({ messages: messages.reverse() });
}));
app.use((error, req, res, next) => {
  const status = error.status || 500;
  res.status(status).json({ error: status === 500 ? 'internal error' : error.message });
});

const server = http.createServer(app);
const io = new Server(server, { cors: { origin: '*' } });
io.use(async (socket, next) => {
  try {
    socket.data = await authenticate(socket.handshake.auth?.token);
    next();
  } catch {
    next(new Error('authentication failed'));
  }
});

io.on('connection', socket => {
  const { userId } = socket.data;
  socket.join('user:' + userId);
  const handle = handler => async (payload = {}) => {
    try {
      if (Date.now() >= socket.data.expiresAt) fail(401, 'token expired');
      if (!payload || typeof payload !== 'object') fail(400, 'invalid payload');
      await handler(payload);
    } catch (error) {
      socket.emit('error', {
        code: error.status || 500,
        message: error.status ? error.message : 'internal error'
      });
    }
  };

  socket.on('join_room', handle(async ({ roomId }) => {
    await requireRoom(roomId, userId);
    socket.join('room:' + roomId);
    socket.emit('joined', { roomId });
  }));
  socket.on('leave_room', handle(async ({ roomId }) => {
    if (!validId(roomId)) fail(400, 'invalid room id');
    socket.leave('room:' + roomId);
  }));
  socket.on('send_message', handle(async ({ roomId, content, mentions = [], tempId }) => {
    if (typeof content !== 'string' || !content.trim() || content.length > 10000) {
      fail(400, 'invalid content');
    }
    const room = await requireRoom(roomId, userId);
    if (!Array.isArray(mentions) || mentions.length > 50 || !mentions.every(validId)) {
      fail(400, 'invalid mentions');
    }
    const mentionIds = [...new Set(mentions)];
    const members = new Set(room.members.map(String));
    if (mentionIds.some(id => !members.has(id))) fail(403, 'mention outside room');
    const mentionedUsers = await User.find({ _id: { $in: mentionIds } });
    if (mentionedUsers.length !== mentionIds.length) fail(400, 'unknown mention');
    const escapeRegex = value => value.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&');
    for (const user of mentionedUsers) {
      const pattern = new RegExp('(^|[^A-Za-z0-9_])@' +
        escapeRegex(user.username) + '(?=$|[^A-Za-z0-9_])');
      if (!pattern.test(content)) fail(400, 'mention missing from content');
    }

    const msg = await new Message({
      roomId, senderId: userId, content, mentions: mentionIds
    }).save();
    const message = {
      _id: msg._id, roomId, senderId: userId, content,
      mentions: mentionIds, createdAt: msg.createdAt
    };
    io.to('room:' + roomId).emit('message', message);
    socket.emit('message_ack', { tempId, messageId: msg._id });
    for (const uid of mentionIds) {
      io.to('user:' + uid).emit('mention', {
        roomId, messageId: msg._id, from: userId, content
      });
    }
  }));
  socket.on('typing', handle(async ({ roomId, isTyping }) => {
    await requireRoom(roomId, userId);
    if (typeof isTyping !== 'boolean') fail(400, 'invalid typing state');
    socket.to('room:' + roomId).emit('typing', { roomId, userId, isTyping });
  }));
});

async function start() {
  await mongoose.connect(MONGO);
  server.listen(3000, () => console.log('listening 3000'));
}
start().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

How to run:

  1. Start MongoDB, prepare users with bcrypt passwordHash and rooms with user members, then set a separate random JWT_SECRET before running node server.js.
  2. Send username/password to POST /login for a token, connect with io({ auth: { token }}) and separately send Authorization: Bearer <token> on history requests.

Minimal example of frontend (HTML + socket.io-client) ​

Installation: Introduce socket.io-client (CDN or bundler) on the front-end page.

Example index.html:

html
<!doctype html>
<html>
  <head><meta charset="utf-8"><title>Chat Demo</title></head>
  <body>
    <div>
      <input id="token" placeholder="paste token from /login" style="width:400px"/>
      <button id="connectBtn">Connect</button>
    </div>

    <div id="chat" style="display:none;">
      <div>
        Room: <input id="roomId" value="" />
        <button id="join">Join</button>
      </div>

      <div id="messages" style="height:300px;overflow:auto;border:1px solid #ccc;padding:8px;"></div>

      <div>
        <input id="msg" style="width:70%" placeholder="@username to mention" />
        <button id="send">Send</button>
      </div>
    </div>

    <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
    <script>
      let socket;
      document.getElementById('connectBtn').onclick = () => {
        const token = document.getElementById('token').value;
        socket = io('http://localhost:3000', { auth: { token }});
        socket.on('connect', () => {
          console.log('connected', socket.id);
          document.getElementById('chat').style.display = 'block';
        });
        socket.on('message', (m) => {
          const el = document.createElement('div');
          el.innerText = `[${new Date(m.createdAt).toLocaleTimeString()}] ${m.senderId}: ${m.content}`;
          document.getElementById('messages').appendChild(el);
        });
        socket.on('mention', (x) => {
          const el = document.createElement('div');
          el.style.background = '#ffc';
          el.innerText = `MENTION from ${x.from} in ${x.roomId}: ${x.content}`;
          document.getElementById('messages').appendChild(el);
        });
      };

      document.getElementById('join').onclick = () => {
        const roomId = document.getElementById('roomId').value;
        socket.emit('join_room', { roomId });
      };
      document.getElementById('send').onclick = () => {
        const roomId = document.getElementById('roomId').value;
        const content = document.getElementById('msg').value;
        // 简化:客户端解析 @username => 替换为 userId (生产需先 resolve username->id)
        // 这里示例直接不解析 mentions
        socket.emit('send_message', { roomId, content, mentions: [] , tempId: 't'+Date.now()});
      };
    </script>
  </body>
</html>

Note: In actual production, before the client sends the message Must put @username parsed into userId And in mentions The array is passed to the backend (the autocomplete interface can be called when inputting GET /users?query=... )。

Implementation details of @mentions ​

  • Client: Implement @ in the input box to trigger autocomplete and return matching username and id (REST: GET /users?query=...).
  • Sending: this example uses mentions: [userId]; adding position/username objects requires matching protocol and server validation changes.
  • Server: check sender and mentioned-user membership and validate @username in content. The example uses basic username boundaries; rich text should use structured mention nodes.
  • Notification: If the user is offline, push mobile notification or email.And display the unread mention list on the UI.

Store unread and read ​

Two common options:

  1. readBy field memory reads time per person (suitable for small groups); writing cost is higher than query cost.
  2. The user_rooms table maintains lastReadAt (or lastReadMessageId), which determines unreads by comparing message times/IDs (more common and scalable).

Security and Performance Recommendations (Mastery Section) ​

  • Authentication: Mandatory jwt verification during WebSocket handshake; all socket events are checked for permissions on the server side (do not trust the client).
  • Rate control: Limit the flow of send_message to prevent screen flushing (Redis token bucket).
  • Content filtering: Perform sensitive word filtering/XSS filtering (for example, use the library to safely render contentHtml).
  • Anti-duplication/Impotent: The client sends a message with tempId, the server returns message_ack after deduplication, and the client replaces it with a temporary message.
  • Horizontal expansion: Use the socket.io-redis adapter when deploying multiple instances, and rely on Redis pub/sub to synchronize messages and presence.
  • Storage and Indexing: Establish roomId + createdAt index in Message collection to query history and paging efficiently.
  • Backup and Archiving: Archive or cold store expired messages to avoid expansion of the main database.
  • Message Audit/Compliance: If there are compliance requirements, keep complete audit logs, deletion policies and export functions.
  • File/Large Attachment: The file is not directly uploaded to DB, but object storage (S3) is used to store the metadata and URL in the message document, and generate a temporary access token for the private file.
  • Privacy: Encrypt sensitive fields (for example, some companies need to encrypt content).

Suggestions for advanced features (optional) ​

  • Rich text, emoticons, replies (reference messageId), message withdrawal, editing.
  • Online/offline device push (FCM/APNs).
  • Event sourcing or Kafka message bus is used in complex scenarios.
  • Search (Elasticsearch) is used for chat content search.
  • Typing indicator, read receipts (read receipts), user online list.
  • Group management (invite, kick out, permission hierarchy, announcement, pinned message).

Deployment & Operation and Maintenance Key Points ​

  • Use HTTPS + WSS in production.
  • A Redis adapter forwards events between instances; HTTP long-polling still needs sticky sessions. WebSocket-only transport can omit session affinity.Deployment documentation.
  • Monitoring: number of connections, message throughput, latency, error rate, memory/GC.
  • Grayscale, rollback strategy, log collection (structured log).