Building real-time applications with React and WebSockets
By Adam Hultman · 9 min read

Real-time features can make an app feel alive.
Chat messages appear without refreshing. Notifications show up the second something happens. Dashboards update while you watch them. Collaborative tools stop feeling like everyone is emailing a spreadsheet around like it is 2007.
That is the fun part.
The less fun part is that real-time systems come with a few new problems: dropped connections, duplicate events, reconnects, stale state, scaling issues, and users who close their laptop halfway through an action and then blame you.
For React apps, Socket.IO is a great way to get started. It gives you real-time, event-based communication between your client and server, plus helpful features like reconnection, rooms, and fallback transports.
Let’s walk through how it works, how to wire it into React, and what to watch out for before calling it production-ready.
What Are WebSockets?
Normal HTTP requests are like sending a text.
The browser asks the server for something, the server responds, and the conversation ends.
That works well for most app interactions:
1
2
Client: Can I have this user profile?
Server: Here you go.WebSockets are more like a phone call.
The client and server open a connection and keep it open, which lets both sides send messages whenever they need to.
That makes WebSockets useful for things like:
- Chat
- Live notifications
- Multiplayer games
- Collaborative editing
- Live dashboards
- Presence indicators
- Progress updates for long-running jobs
Instead of the client constantly asking, “Anything new yet? Anything new yet? Anything new yet?”, the server can push updates as soon as they happen.
Much less annoying. Very polite.
WebSockets vs Socket.IO
One important thing: Socket.IO is not exactly the same thing as raw WebSockets.
Raw WebSockets give you a persistent two-way connection. Socket.IO builds on that idea and adds a bunch of useful features, including:
- Automatic reconnection
- Fallback to HTTP long-polling when WebSockets are not available
- Event-based messaging
- Rooms
- Namespaces
- Heartbeats
- Adapters for scaling across multiple servers
For most app-level real-time features, Socket.IO is a nice developer experience. If you need maximum low-level control or extremely high-throughput infrastructure, you might reach for raw WebSockets or another specialized tool.
But for a chat feature, live notifications, or a collaborative UI, Socket.IO is a very reasonable place to start.
Setting Up a Basic Socket.IO Server
First, install the server dependencies:
1
npm install express socket.ioThen create a basic server:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: 'http://localhost:3000',
methods: ['GET', 'POST'],
},
});
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
socket.on('message', (data) => {
console.log('Message received:', data);
io.emit('message', {
id: crypto.randomUUID(),
text: data,
createdAt: new Date().toISOString(),
});
});
socket.on('disconnect', (reason) => {
console.log('User disconnected:', socket.id, reason);
});
});
const PORT = process.env.PORT || 4000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});This server listens for new socket connections. When a client sends a message event, the server broadcasts that message to every connected client.
That is enough for a tiny demo chat app.
Is it production-ready? Absolutely not. But tiny demos need love too.
Connecting a React Client
Now install the client package:
1
npm install socket.io-clientFor a simple app, you can create the socket connection in a separate file:
1
2
3
4
5
6
// src/socket.js
import { io } from 'socket.io-client';
export const socket = io('http://localhost:4000', {
autoConnect: false,
});Setting autoConnect: false gives you more control. Instead of connecting as soon as the file is imported, you can connect when the component mounts.
Here is a basic React chat example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// src/App.jsx
import { useEffect, useState } from 'react';
import { socket } from './socket';
export default function App() {
const [isConnected, setIsConnected] = useState(socket.connected);
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
useEffect(() => {
function handleConnect() {
setIsConnected(true);
}
function handleDisconnect() {
setIsConnected(false);
}
function handleMessage(message) {
setMessages((previousMessages) => [
...previousMessages,
message,
]);
}
socket.on('connect', handleConnect);
socket.on('disconnect', handleDisconnect);
socket.on('message', handleMessage);
socket.connect();
return () => {
socket.off('connect', handleConnect);
socket.off('disconnect', handleDisconnect);
socket.off('message', handleMessage);
socket.disconnect();
};
}, []);
function sendMessage() {
const trimmedInput = input.trim();
if (!trimmedInput) {
return;
}
socket.emit('message', trimmedInput);
setInput('');
}
return (
<main>
<h1>Real-Time Chat</h1>
<p>Status: {isConnected ? 'Connected' : 'Disconnected'}</p>
<div>
{messages.map((message) => (
<p key={message.id}>{message.text}</p>
))}
</div>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Type your message..."
/>
<button onClick={sendMessage}>Send</button>
</main>
);
}There are a few small details here that matter.
First, the event handlers are named functions. That makes cleanup easier because socket.off receives the same function reference that was passed to socket.on.
Second, messages use an id instead of the array index. That is better for rendering lists in React, especially once messages can be inserted, removed, retried, or synced from a server.
Third, the socket disconnects when the component unmounts. That is good for a simple demo. In a larger app, you might keep the socket alive at a higher level, like in a provider, so it is shared across routes.
Handling Connection State
Real-time features need to handle messy network reality.
The user might close their laptop. Their phone might switch from Wi-Fi to cellular. The server might restart. A proxy might interrupt the connection. The app might go into the background.
You want to know what state the connection is in so your UI can respond properly.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
useEffect(() => {
function handleConnect() {
console.log('Connected:', socket.id);
}
function handleDisconnect(reason) {
console.log('Disconnected:', reason);
}
function handleReconnectAttempt(attempt) {
console.log('Reconnect attempt:', attempt);
}
socket.io.on('reconnect_attempt', handleReconnectAttempt);
socket.on('connect', handleConnect);
socket.on('disconnect', handleDisconnect);
return () => {
socket.io.off('reconnect_attempt', handleReconnectAttempt);
socket.off('connect', handleConnect);
socket.off('disconnect', handleDisconnect);
};
}, []);This can help you show useful UI states like:
- Connected
- Reconnecting
- Offline
- Message failed to send
- Trying again
That sounds small, but it makes the app feel way more stable.
A real-time app should not just work when the connection is perfect. That is the easy mode. Production is where Wi-Fi goes to be weird.
Updating React State Safely
When events arrive from the socket, you usually update React state.
For small volumes of events, this is totally fine:
1
2
3
4
5
6
socket.on('message', (message) => {
setMessages((previousMessages) => [
...previousMessages,
message,
]);
});The important part is using the functional state update:
1
setMessages((previousMessages) => [...previousMessages, message]);That keeps you from accidentally reading stale state inside the event handler.
For high-frequency events, like cursor movement, live metrics, or multiplayer updates, you may need a different approach. Updating React state hundreds of times per second can get expensive.
In those cases, consider:
- Batching updates
- Throttling events
- Debouncing UI updates
- Storing fast-changing values in refs
- Using a state library designed for frequent external updates
- Rendering only the part of the UI that actually changed
Chat messages are easy. Live cursors and dashboards can get spicy.
Using Rooms
Broadcasting every event to every user is fine for a demo, but most real apps need more control.
That is where rooms are useful.
For example, users in one chat room should not receive messages from every other chat room.
On the server:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
io.on('connection', (socket) => {
socket.on('join-room', (roomId) => {
socket.join(roomId);
});
socket.on('room-message', ({ roomId, message }) => {
io.to(roomId).emit('room-message', {
id: crypto.randomUUID(),
roomId,
text: message,
createdAt: new Date().toISOString(),
});
});
});On the client:
1
2
3
4
5
6
socket.emit('join-room', roomId);
socket.emit('room-message', {
roomId,
message: 'Hello room!',
});Rooms are useful for:
- Chat rooms
- Project workspaces
- Organization-specific updates
- Document collaboration
- User-specific notifications
Just remember that joining a room is not authorization by itself. Always check whether the user is actually allowed to join that room.
Security bugs love hiding in real-time features because everyone is busy being impressed that the UI updates instantly.
Authentication and Authorization
Do not treat a socket connection as trusted just because it connected.
You still need to authenticate the user and check what they are allowed to do.
A common pattern is to pass a token when connecting:
1
2
3
4
5
6
export const socket = io('http://localhost:4000', {
autoConnect: false,
auth: {
token: 'user-auth-token',
},
});Then verify it on the server:
1
2
3
4
5
6
7
8
9
10
11
12
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = verifyToken(token);
socket.user = user;
next();
} catch {
next(new Error('Unauthorized'));
}
});Then, before joining a room or sending a message, check permissions:
1
2
3
4
5
6
7
8
9
socket.on('join-room', async (roomId) => {
const canJoin = await userCanAccessRoom(socket.user.id, roomId);
if (!canJoin) {
return;
}
socket.join(roomId);
});The same rule applies here as it does with normal APIs: authentication tells you who the user is. Authorization tells you what they can access.
You need both.
Rate Limiting and Validation
Real-time endpoints are easy to spam.
If a user can emit a message event once, they can probably emit it 500 times in a loop. That can annoy users, overload your server, or fill your database with garbage.
At minimum, validate incoming payloads:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
socket.on('message', (data) => {
if (typeof data !== 'string') {
return;
}
const message = data.trim();
if (!message || message.length > 1000) {
return;
}
io.emit('message', {
id: crypto.randomUUID(),
text: message,
createdAt: new Date().toISOString(),
});
});For production apps, add rate limiting too. For example:
- Limit messages per user per second
- Limit room joins
- Limit expensive events
- Disconnect abusive clients
- Log suspicious behavior
Do not assume users will only click the button you gave them. Some users will open DevTools and start conducting experiments. Little scientists of chaos.
Scaling Socket.IO
A single server can handle a lot for small apps, but scaling WebSockets has a few extra wrinkles.
With normal HTTP, each request is short-lived. With sockets, each user keeps a connection open. That changes how you think about capacity and deployment.
When scaling across multiple server instances, you usually need to think about:
- Load balancing
- Sticky sessions
- Shared pub/sub between instances
- Connection limits
- Reconnection storms
- Monitoring active connections
Socket.IO supports adapters, like the Redis adapter, to help multiple server instances broadcast events to each other.
The idea is simple: if one user is connected to server A and another user is connected to server B, both servers need a way to share events.
At a small scale, you can keep things simple. At a larger scale, plan for it early enough that your real-time feature does not become one very enthusiastic single point of failure.
Monitoring Real-Time Features
Real-time systems need observability.
You should know:
- How many clients are connected
- How often clients disconnect
- How often they reconnect
- How many events are being sent
- Which events are slow
- Which events fail validation
- Whether one room or tenant is creating unusual load
Logs are useful, but metrics are even better. You want to spot patterns before users start reporting that “chat feels weird.”
That bug report is technically honest, but spiritually unhelpful.
Final Thoughts
Real-time features are fun because they make your app feel alive.
They are also a little trickier than normal request-response code. Connections drop. Events arrive twice. Users reconnect. State gets stale. Servers restart. Somewhere, someone closes their laptop at the exact wrong moment.
Socket.IO gives you a friendly way to handle a lot of that complexity, especially for chat, notifications, dashboards, and collaborative features.
Start simple:
- Set up the server
- Connect the React client
- Clean up event listeners
- Track connection state
- Validate every event
- Add rooms when needed
- Plan for scaling before things get too exciting
The goal is not just to make updates appear instantly.
The goal is to make them appear instantly, reliably, and without turning your server into a spooky walkie-talkie.
Keep reading
React: compound components vs render props vs custom hooks
Oct 20, 2024
Top mistakes to avoid when deploying react apps to production
Oct 18, 2024
How to build a scalable React app with Next.js and TypeScript
Oct 18, 2024