Architectural Patterns & Considerations for Socket.IO
In Part I of our Socket.IO blog series, we gave a high-level introduction to what Socket.IO is, what it's used for, the role of WebSocket connections, and the importance of scalability for your real-time app.
Part II dives into a couple of the different architectural patterns and the considerations for each. Let's get started!
Socket.IO Architectural Design Patterns
Software developers often turn to open-source Socket.IO for its powerful features and flexibility when building real-time mobile and web applications. However, it’s important to consider the architectural patterns that can be implemented with Socket.IO to ensure a scalable and secure platform. Here are some to consider:
Server-Centric Architecture
In this pattern, the server plays a central role in handling and managing real-time communication. Clients connect to the server using Socket.IO as middleware and send messages that are broadcast to other connected clients. The server is responsible for maintaining the state of each client and managing the connections. This architecture is useful for applications where the server needs full control over the communication and ensure data consistency. To set up a server-centric architecture with Socket.IO, you can follow the steps outlined below:
STEP 1: Install Socket.IO
First, make sure you have Node.js installed on your system. Then, you can install Socket.IO by running the following command in your terminal or command prompt:
npm install socket.io
STEP 2: Initialize Socket.IO in your server code
In your server-side code, import the Socket.IO module and initialize it with your server instance. Here’s an example using Express.js as the server framework:
const express = require('express'); const app = express(); const http = require('http').createServer(app); const io = require('socket.io')(http);
// Your server code here http.listen(3000, () => { console.log('Server listening on port 3000'); });
STEP 3: Handle client connections and messages
With Socket.IO initialized, you can start handling client connections and messages. Here’s an example of how you can listen for client connections and receive and send messages:
io.on('connection', (socket) => { console.log('A client connected');
// Listen for incoming messages from the client socket.on('chat message', (message) => { console.log('Received message:', message);
// Broadcast the message to all connected clients io.emit('chat message', message); });
// Handle client disconnections socket.on('disconnect', () => { console.log('A client disconnected'); }); });
STEP 4: Set up client-side Socket.IO connection
You can set up a Socket.IO connection on the client-side by including the Socket.IO library in your HTML file and connecting to the server. Here’s an example:
// Connect to the server const socket = io();
// Handle form submission document.getElementById('chat-form').addEventListener('submit', (e) => { e.preventDefault(); const messageInput = document.getElementById('message-input'); const message = messageInput.value;
// Send the message to the server socket.emit('chat message', message);
// Clear the input field messageInput.value = ''; });
// Listen for incoming messages from the server socket.on('chat message', (message) => { const messageElement = document.querySelector('#messages'); const newMessage = document.createElement('li'); newMessage.textContent = message; messageElement.appendChild(newMessage); });
STEP 5: Enhance the chat application
You can enhance your chat application by adding features like user authentication, private messaging, and typing indicators. Here are a few examples:
User authentication:
You can use a library like Passport.js to handle user authentication and authorization in your chat application. This will allow you to authenticate users and restrict access to certain features or chat rooms.
Private messaging:
You can implement private messaging by creating separate chat rooms for individual users or groups. When a user sends a chat message, you can use Socket.IO to only send the message to the specific recipient(s).
Typing indicators:
To show typing indicators in your chat application, you can listen for the “typing” event on the client side and emit the event when a user starts or stops typing. On the recipient’s end, you can display a typing indicator to indicate that the sender is currently typing a message.
Peer-to-Peer Architecture
In a peer-to-peer architecture, Socket.IO establishes direct connections between clients without relying heavily on the server. Clients can send messages directly to each other without going through the server, which reduces latency and server load. This architecture is suitable for applications where clients need to communicate with each other directly, such as multiplayer gaming or file-sharing applications. To set up a Peer-to-Peer (P2P) architecture with Socket.IO, you can follow these steps:
STEP 1: Understand Peer-to-Peer Architecture
Peer-to-peer architecture allows direct communication between clients without needing a central server. In this architecture, each client can act as a client and a server, enabling them to send and receive data directly.
STEP 2: Set up Socket.IO Server
First, you must set up a Socket.IO server to handle the initial connection and facilitate the P2P communication. You can use Node.js and the Socket.IO library to create the server. Install Socket.IO using npm:
npm install socket.io
Create a server.js file and import Socket.IO:
const io = require('socket.io')();
Set up the connection event listener:
io.on('connection', (socket) => { // Handle incoming connections });
STEP 3: Establish P2P Connection
Inside the connection event listener, you can handle the logic for establishing the P2P connection between clients. When a client connects to the server, you can save its socket ID and listen for specific events. To establish a P2P connection, you can emit a custom event from the client side when a user wants to initiate a connection. For example, when a user wants to start a chat with another user:
socket.emit('start-chat', otherUserId);
On the server side, you can listen for this event and handle the logic to establish the P2P connection between the two clients. You can use the socket IDs to send messages directly between the clients without involving the server.
STEP 4: Handle P2P Communication
Once the P2P connection is established, you can handle the communication between clients.
You can listen for custom events on both the client and server sides to exchange messages, files, or any other data.
For example, when a client wants to send a message to another client:
socket.emit('send-message', { recipientId: otherUserId, message: 'Hello!' });
On the server-side, you can listen for this event and send the message to the recipient client:
socket.on('send-message', ({ recipientId, message }) => { // Find the recipient client by their socket ID const recipientSocket = io.sockets.connected[recipientId]; // Send the message to the recipient client recipientSocket.emit('receive-message', { senderId: socket.id, message }); });
STEP 5: Handle P2P Disconnection
Lastly, you need to handle P2P disconnections. When a client disconnects from the server, you should remove its socket ID from the saved connections. You can use the ‘disconnect’ event to handle this:
socket.on('disconnect', () => { // Remove the socket ID from the saved connections });
This code snippet provides a basic example of how to set up a real-time chat application using the Socket.IO library in JavaScript. The steps outlined guide developers through establishing peer-to-peer connections, handling communication between clients, and managing disconnections.
Hybrid Architecture
The hybrid architecture combines both server-centric and peer-to-peer patterns. Clients connect to the server using Socket.IO but can also establish direct connections when necessary. This allows for a balance between centralized control and direct communication between clients. Hybrid architectures, such as video conferencing or social networking platforms, are commonly used in applications requiring real-time broadcasting and one-on-one communication. To set up a hybrid architecture with Socket.IO, you can follow these steps:
STEP 1: Design your architecture
Before starting the implementation, it is important to design your hybrid architecture. Determine which components of your application will be running on the server side and which will be running on the client side. This will help you understand how Socket.IO fits into your overall architecture.
STEP 2: Set up the server
On the server side, you must set up a Socket.IO server to handle real-time communication. You can use a framework like Node.js with Express to create the server. Install the Socket.IO library and initialize it in your server code.
STEP 3: Establish peer-to-peer connections
To establish peer-to-peer connections, you must save the socket IDs of connected clients on the server. When a client connects to the server, their socket ID is saved. This can be done using the ‘connection’ event in Socket.IO.
STEP 4: Handle peer-to-peer communication
Once the peer-to-peer connection is established, clients can communicate directly without involving the server. Clients can send messages to each other by emitting a custom event, such as ‘send- message’, from the client side. Listen for this event on the server side and send the message to the recipient client using their socket ID.
STEP 5: Handle P2P disconnection
When a client disconnects from the server, their socket ID should be removed from the saved connections to avoid memory leaks. This can be done using the ‘disconnect’ event in Socket.IO. Handle this event and perform the necessary cleanup by removing the disconnected client’s socket ID from the saved connections. In addition to the steps mentioned above, developers can also leverage the features provided by Socket.IO, such as rooms and namespaces, to create more complex and advanced real-time applications.
Rooms allow for grouping clients and sending messages to specific groups, while namespaces provide a way to separate different parts of the application and handle communication within those parts separately. Socket.IO also supports various transport mechanisms, such as WebSocket, HTTP long-polling, and AJAX, ensuring that the application can adapt to different network conditions and provide a seamless real-time experience for users.
Architectural Considerations
Horizontal vs. vertical scaling
When an existing system fails to handle increasing workloads, possibly the most common and effective scaling options for any cloud application are horizontal and vertical scaling. Horizontal scaling refers to adding additional servers or nodes to your infrastructure to support growing demands. Meanwhile, vertical scaling adds new resources to the existing system to manage the increasing workload. You can use both approaches to scale your Socket.io application, but if you’re looking to scale indefinitely, vertical scaling may limit how much you can grow. Horizontal scaling may be the better option for scaling Socket.io, especially if you want to future-proof your application and reduce the chances of downtime. However, horizontal scaling also introduces some technical complexity. Servers must share the burden evenly to ensure low latency. This is where load balancers and reverse proxies come in.
Load balancers and reverse proxies
Both reverse proxy and load balancers are intermediaries in a client-server architecture, working to make data exchange more efficient.
Load balancer
A load balancer distributes incoming messages among a group of servers while returning the response from the selected server to the appropriate client. It tries to get the most out of each server’s capacity. It offers benefits like:
Preventing server overload to ensure consistent performance
Ensuring quick responses to provide a great user experience
Reverse proxy
Reverse proxies accept requests from a client using edge devices and forward them to the correct server. nginx is an example that is both a load balancer and reverse proxy. They offer benefits like:
Preventing malicious attacks targeting server information
Giving you the freedom to configure backend infrastructure
Difference between load balancers and reverse proxies
Although, at first glance, they perform similar functions, load balancers and reverse proxies differ in a very important aspect. Load balancers are commonly deployed in instances involving multiple servers, while reverse proxies work best with just one web or application server. This difference also defines how Socket.IO applications use them.
Best practices for implementing load balancing and reverse proxies
When implementing load balancers, choose the right algorithm for your use case when distributing requests across servers. This ensures minimal latency and bandwidth costs during data exchange.
Remember to set proper expiration dates for your reverse proxy caches so your application doesn’t use outdated cache data to deliver real-time experiences.
Configure security requirements for load balancers and reverse proxies to ensure your Socket.io application is safe from external attacks.
A quick note for those following along: It should be noted that the code we shared earlier in this white paper designs a single, non-durable system and, therefore, will not support load balancing without additional considerations. One solution would be to implement a Sharding strategy that uses a Shard Key to allow connectivity to users who need to exchange information. This is required for a production scale system, which enables high availability for a realtime system. Another solution would be to simply use PubNub. Instead of creating a consistent, key-based load-balancing deployment, PubNub is built in a way designed for high concurrency and availability.
Microservices
Microservices are smaller services that can be combined to create a more extensive application. These smaller services make it easier to scale as you only need to work on the microservice that needs updates while others continue to function independently.
However, microservices bring plenty of development and workflow complexities:
Many individual services make it difficult to trace bugs or errors. Establishing proper communication channels and processes to streamline workflows is vital for faster development.
Microservices require multiple teams with varying expertise.
Group testing microservices is difficult because they usually don’t use the same programming language. Ensure you have the right testing tools available.
Our third and final blog in this Socket.IO series'll discuss some best practices for building scalable real-time apps, different ways to secure your Socket.IO applications, and the important role PubNub plays when using Socket.IO.
We enable developers to build real-time interactivity for use cases like IoT, web apps, and mobile devices. The platform runs on our edge messaging network, providing customers with the industry's largest and most scalable global infrastructure for interactive applications. With over 15 points of presence worldwide supporting 800 million monthly active users, hundreds of millions of chat messages, and 99.999% reliability, you’ll never have to worry about outages, concurrency limits, or any latency issues caused by traffic spikes. By leveraging our infrastructure, APIs, SDKs, extensive library of step-by-step tutorials, and GitHub, developers can focus on creating innovative and engaging user experiences.
Sign up for a free trial and get up to 200 MAUs or 1M total transactions per month include