I handled real-time chat, prices and the online user count in one go with Socket.IO
On the crypto site, one Socket.IO connection handles chat, price alerts and the online user count. When to use socket.emit and when io.emit, with the real server code.
#SocketIO #Realtime #WebSocket #Backend
While building the crypto site, I ended up with quite a lot to handle in real time. Prices keep changing, chat messages have to show up right away, and I also wanted to show how many people were watching at the moment. SSE, which I covered earlier, only goes one way, from server to client, and for something like chat where both sides have to send, WebSocket is the right fit. So I used Socket.IO . In this post I go over how I handled chat, price alerts and the online user count together over a single connection. There's a reason one connection does it all instead of three separate channels. On connect, send the past chat first In Socket.IO, a connection event fires when a client connects. It's awkward for a new user to come in and find the chat window completely empty, so as soon as the connection is made I pull the latest 100 messages from the DB and send them. // app.js const MAX_MESSAGES = 100; io.on('connection', async (socket) = { try { const rows = await query( 'SELECT id, msg_id, username, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?', [MAX_MESSAGES] ); const totalCount = await query('SELECT COUNT(*) as cnt FROM chat_messages'); // Send to this user only (socket.emit = only to me) socket.emit('previousMessages', { messages: rows.reverse(), // Fetched newest first, so flip it back hasMore: totalCount[0].cnt MAX_MESSAGES, // Whether there's more to load above }); } catch (err) { socket.emit('previousMessages', { messages: [], hasMore: false }); } io.emit('userCount', io.engine.clientsCount); // The user count goes to everyone }); Here socket.emit sends only to the person who just connected. Only the newcomer needs the history, so there's no reason to send it to everyone else again. Even if the DB query fails, I send an empty list so the client isn't left waiting forever. hasMore is sent along so that older messages can be loaded when the chat window is scrolled up. When the client sends loadMoreMessages, I give it 50 more at a time with ids smaller than the last message's id. If I sent everything from the start, once tens of thousands of messages piled up, every connection would download all of them. Telling the two kinds of emit apart is all there is to it The first thing to get used to in Socket.IO is "who are you sending to". socket.emit goes only to the one person on this connection, and io.emit goes to everyone connected. Past messages go only to me with socket.emit, and a new chat message goes to everyone with io.emit. When someone types in chat, it has to show up for everyone at the same time. As long as you don't mix these two up, the rest is naming events. socket.on('chatMessage', async (data) = { const message = { id: Date.now() + Math.random(), username: data.username || 'Anonymous', message: data.message, timestamp: new Date().toISOString(), }; try { await query('INSERT INTO chat_messages (msg_id, username, message) VALUES (?, ?, ?)', [String(message.id), message.username, message.message]); } catch (err) { console.error('Failed to save chat:', err); } io.emit('chatMessage', message); // To everyone, including the sender }); Generating the message id on the server instead of using a value sent by the client was deliberate too. If the client decides it, the same id can come in twice, and then messages get merged into one or deleted on the screen. Even if saving fails, the message still goes out. A chat message not showing up is a bigger problem than it not being saved. Price alerts go out over the same connection I made a bot message appear in the chat window when Bitcoin goes above or falls below certain prices, and this uses the same Socket.IO connection as is. The server checks the price every 15 seconds and sends to everyone when it catches a move above or below a level. setInterval(checkBtcMilestone, 15 * 1000); // Check every 15 seconds async function announce(ko, en, positive, banner) { const message = { id: Date.now() + Math.random(), username: '__BOT__', message: ko, messageEn: en, timestamp: new Date().toISOString() }; try { await query('INSERT INTO chat_messages (msg_id, username, message) VALUES (?, ?, ?)', [String(message.id), message.username, ko]); } catch {} io.emit('chatMessage', message); // As a bot message in the chat window if (banner) io.emit('btcAlert', { message: ko, messageEn: en, isBreakout: positive }); // Top banner } Bot messages are sent with the username hardcoded to __BOT__. The client draws them in a different color when it sees this name. I also put them in the DB, so that someone who comes in later can see in the chat history that "oh, it went over 100 million won earlier". The alert has to go out even if the INSERT fails, so the error is swallowed quietly inside the try. Chat and price alerts look like completely different features, but in the end they're the same pattern: "when something happens on the server, tell everyone". Since one connection handles both, the client only has to hold one socket, and reconnection only has to be handled in one place. The user count comes almost for free I don't keep my own count of how many people are watching. Socket.IO keeps the current number of connections in io.engine.clientsCount, so all I do is send that number to everyone whenever someone comes in or leaves. io.on('connection', (socket) = { io.emit('userCount', io.engine.clientsCount); // When someone joins socket.on('disconnect', () = { io.emit('userCount', io.engine.clientsCount); // When someone leaves }); }); Strictly speaking it's not the "number of users" but the "number of open sockets". If one person opens two tabs, it counts as 2. I decided to live with that. Grouping by session or IP would multiply the amount of code several times over, and this number is there to give a sense of the mood, not to be an exact statistic. This is the screen with prices, chat and the user count all flowing over one connection. This wasn't the first time I tried adding it According to the git log, I first added sockets to this project in July 2024. The commits from that day look like this. 2024-07-22 socket 2024-07-22 test 2024-07-22 error 2024-07-22 error message 2024-07-22 socket Back then I just kept adding it and removing it, and in the end I gave up. Given that the commit messages are error and test, I don't think I even knew what the problem was. A year and a half later I added it again, and that time it worked. When I think about what changed in between, it wasn't so much that I knew the technology better. The difference was that I started writing the code on the assumption that the connection can drop. The first time, I wrote it believing that once a connection was made it would stay up. So there was no code to resubscribe after a reconnect and no code to fetch the past messages again, and the screen froze as it was after even a brief disconnect. If you're adding Socket.IO for the first time, I'd recommend deciding where to use socket.emit and where to use io.emit, and what has to be sent again after a drop and reconnect, before deciding how many events to make. The rest can grow as you build.