Explore Blogs
Building a decentralized chat application with websockets: Privacy-focused p2p communication

Explore how to create a decentralized chat application using WebSockets for peer-to-peer communication, focusing on privacy by preventing the server from knowing which clients are communicating with each other. This guide dives into setting up a signaling server, handling client connections, and implementing secure message relaying to ensure confidentiality.
In today's digital age, privacy is paramount. As developers, we have a responsibility to build applications that respect user privacy and data security. Chat applications, in particular, often handle sensitive information, making privacy a critical concern. But how can we ensure the conversations are private without routing them through central servers?
This article explores how to create a decentralized chat application using WebSockets. We'll design a system where a central server facilitates initial connections but then steps aside, allowing peers to communicate directly. Our primary goal is to prevent the server from knowing which clients are talking to each other, ensuring user privacy.
Understanding the core concepts
Before we dive into the code, let's clarify the core concepts behind our decentralized chat application.
- Decentralization: Distributing the communication load across multiple clients rather than relying on a central server for all message routing. This enhances privacy and reduces the risk of a single point of failure.
- WebSockets: A communication protocol that provides full-duplex communication channels over a single TCP connection. WebSockets enable real-time data transfer between a client and a server. In our case, we will use it for initial setup and peer discovery.
- Signaling Server: A server that assists clients in discovering and establishing peer-to-peer connections. Once the connection is established, the server is no longer involved in the direct communication between peers.
- Peer-to-Peer (P2P): A network architecture in which devices (peers) share data with each other directly, without needing a central server.
Architecture overview
Our application will consist of the following components:
- WebSocket server: A Node.js server using the
ws
library to handle WebSocket connections. This server acts as the signaling server. - Client-side application: A JavaScript application running in the browser that connects to the WebSocket server and handles peer-to-peer communication.
Here's a step-by-step overview of how the application works:
- Client registration: When a client connects to the WebSocket server, it registers itself with a unique ID.
- Peer discovery: Clients can request a list of available peers from the server. The server provides this list without knowing the intent of any specific connection.
- Direct connection: Clients use the peer list to establish direct WebSocket connections with each other. The signaling server is no longer involved in message routing.
- Message relaying: Once a direct connection is established, clients can send messages to each other without the server's involvement.
Setting up the websocket server
First, let's create a Node.js server using the ws
library to handle WebSocket connections. This server will manage client registration and provide a list of available peers.
// Import the required modules
const WebSocket = require('ws');
const uuid = require('uuid');
// Create a WebSocket server
const wss = new WebSocket.Server({ port: 8080 });
// Store connected clients
const clients = new Map();
// Handle WebSocket connections
wss.on('connection', (ws) => {
// Generate a unique ID for the client
const clientId = uuid.v4();
clients.set(clientId, ws);
console.log(`Client ${clientId} connected`);
// Send the client its ID
ws.send(JSON.stringify({ type: 'clientId', clientId }));
// Handle messages from the client
ws.on('message', (message) => {
try {
const parsedMessage = JSON.parse(message);
switch (parsedMessage.type) {
case 'getPeers':
// Send the list of available peers to the client
const peers = Array.from(clients.keys()).filter((id) => id !== clientId);
ws.send(JSON.stringify({ type: 'peerList', peers }));
break;
case 'relayMessage':
//Relay message to another client. Server should NOT use it in final version.
const targetClientId = parsedMessage.target;
const targetClient = clients.get(targetClientId);
if (targetClient) {
targetClient.send(
JSON.stringify({
type: 'directMessage',
sender: clientId,
content: parsedMessage.content,
})
);
}
break;
default:
console.log(`Received message: ${message}`);
}
} catch (error) {
console.error('Failed to parse message:', error);
}
});
// Handle client disconnection
ws.on('close', () => {
clients.delete(clientId);
console.log(`Client ${clientId} disconnected`);
});
// Handle errors
ws.on('error', (error) => {
console.error(`WebSocket error: ${error}`);
clients.delete(clientId);
});
});
console.log('WebSocket server started on port 8080');
Explanation of the code:
- We import the
ws
library and create a new WebSocket server listening on port 8080. - We use a
Map
calledclients
to store the connected clients, using a unique ID generated by theuuid
library. - When a client connects, we generate a unique ID, store the client in the
clients
map, and send the client its ID. - We handle messages from the client. If the message type is
getPeers
, we send the list of available peers to the client. - Important: The
relayMessage
function is present in the above code for the purpose of demonstrating basic message transfer. However, this can be removed to ensure that the server does not actually relay messages between clients. - When a client disconnects, we remove it from the
clients
map. - We also handle WebSocket errors and remove the client from the
clients
map in case of an error.
To run the server, save the code to a file named server.js
and run node server.js
in your terminal. Make sure you have installed the ws
and uuid
libraries:
npm install ws uuid
Building the client-side application
Next, let's create the client-side application that connects to the WebSocket server and handles peer-to-peer communication. This application will run in the browser and allow users to send and receive messages.
Create an HTML file (e.g., index.html
) with the following content:
<!DOCTYPE html>
<html>
<head>
<title>Decentralized Chat</title>
</head>
<body>
<h1>Decentralized Chat</h1>
<div id="messages"></div>
<input type="text" id="messageInput" placeholder="Enter your message">
<button id="sendButton">Send</button>
<script>
const ws = new WebSocket('ws://localhost:8080');
let clientId;
let peers = [];
let peerConnections = new Map();
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const sendButton = document.getElementById('sendButton');
ws.onopen = () => {
console.log('Connected to WebSocket server');
};
ws.onmessage = event => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'clientId':
clientId = message.clientId;
console.log(`My client ID is ${clientId}`);
break;
case 'peerList':
peers = message.peers;
console.log('Available peers:', peers);
break;
case 'directMessage':
// Process direct message from another client
const messageElem = document.createElement('p');
messageElem.textContent = `Received from ${message.sender}: ${message.content}`;
messagesDiv.appendChild(messageElem);
break;
default:
console.log('Received message:', message);
}
};
ws.onclose = () => {
console.log('Disconnected from WebSocket server');
//clean peer connections
peerConnections.forEach((conn) => {
conn.close();
});
peerConnections.clear();
};
ws.onerror = error => {
console.error('WebSocket error:', error);
};
// Function to connect to a peer directly
function connectToPeer(peerId) {
const peerWs = new WebSocket('ws://localhost:8080'); // Connect directly to the peer
peerConnections.set(peerId, peerWs); // Store the connection
peerWs.onopen = () => {
console.log(`Connected to peer ${peerId}`);
};
peerWs.onmessage = event => {
const message = JSON.parse(event.data);
if (message.type === 'directMessage') {
// Display the received message
const messageElem = document.createElement('p');
messageElem.textContent = `Received from ${message.sender}: ${message.content}`;
messagesDiv.appendChild(messageElem);
} else {
console.log('Received message from peer:', message);
}
};
peerWs.onclose = () => {
console.log(`Disconnected from peer ${peerId}`);
peerConnections.delete(peerId); // Remove the connection
};
peerWs.onerror = error => {
console.error(`WebSocket error connecting to peer ${peerId}:`, error);
peerConnections.delete(peerId); // Remove the connection
};
}
// Request list of peers and connect to each of them
function requestPeersAndConnect() {
ws.send(JSON.stringify({ type: 'getPeers' }));
setTimeout(() => { // Allow time for server response
peers.forEach(peerId => {
if (!peerConnections.has(peerId) && peerId !== clientId) { // Prevent connecting to self or already connected peers
connectToPeer(peerId);
}
});
}, 500);
}
// Send a message to all connected peers
sendButton.addEventListener('click', () => {
const messageText = messageInput.value;
messageInput.value = '';
peerConnections.forEach((peerWs, peerId) => {
if (peerWs.readyState === WebSocket.OPEN) {
peerWs.send(JSON.stringify({
type: 'directMessage',
sender: clientId,
content: messageText
}));
// Display the sent message
const messageElem = document.createElement('p');
messageElem.textContent = `Sent to ${peerId}: ${messageText}`;
messagesDiv.appendChild(messageElem);
} else {
console.log(`Peer ${peerId} is not connected. ReadyState: ${peerWs.readyState}`);
}
});
});
// Request peers and connect every 5 seconds
setInterval(requestPeersAndConnect, 5000);
</script>
</body>
</html>
Explanation of the code:
- We create a new WebSocket connection to the server at
ws://localhost:8080
. - We store the client ID, list of peers, and peer connections in variables.
- When the connection is opened, we log a message to the console.
- When we receive a message from the server, we parse the JSON data and handle different message types:
clientId
: We store the client ID in theclientId
variable.peerList
: We store the list of available peers in thepeers
variable.directMessage
: We display the direct message from another client in the messages div.
- When the connection is closed, we log a message to the console.
- When there is an error, we log the error to the console.
connectToPeer(peerId)
: This function is used to connect directly to another peer. It creates a new WebSocket connection directly to the peer, bypassing the initial signaling server. It handles messages from that peer and displays them.requestPeersAndConnect()
: This function sends a request to the signaling server for a list of available peers. After a short delay to allow time for the server to respond, it iterates through the list of peers and attempts to connect to each one, provided it is not already connected and is not the client's own ID.- When the "Send" button is clicked, we send the message to all connected peers through direct WebSocket connections.
Open the index.html
file in your browser. You should see the "Connected to WebSocket server" message in the console. If you open multiple browser windows, you should see the list of available peers in each window.
Enhancing privacy
While our current implementation prevents the server from knowing the content of messages, it still knows which clients are connecting to the server. To further enhance privacy, we can use techniques like:
- WebSockets over TLS: Encrypting the WebSocket connection using TLS (Transport Layer Security) to prevent eavesdropping. This is already a standard best practice.
- Onion Routing: Using a protocol like Tor to hide the client's IP address from the server. This makes it more difficult for the server to identify and track clients.
- End-to-End Encryption: Encrypting messages on the client-side before sending them to the server, ensuring that only the intended recipient can decrypt the message. This would need to happen regardless of whether the final version of the signaling server relays messages or not.
For the most basic privacy focused method, simply avoid relaying messages in server, and let peers connect each other.
Security considerations
When building a decentralized chat application, it's essential to consider security aspects to protect users from potential threats.
- Data validation: Always validate data received from clients to prevent injection attacks.
- Authentication: Implement authentication mechanisms to ensure that only authorized users can access the chat application.
- Rate limiting: Implement rate limiting to prevent abuse and DoS (Denial of Service) attacks.
- Regular updates: Keep your dependencies up-to-date to patch any known security vulnerabilities.
Scaling considerations
As your decentralized chat application grows, it's important to consider scaling strategies to handle an increasing number of users.
- Horizontal scaling: Distribute the load across multiple WebSocket servers to handle more concurrent connections.
- Load balancing: Use a load balancer to distribute traffic evenly across the WebSocket servers.
- Connection pooling: Reuse existing WebSocket connections to reduce the overhead of establishing new connections.
- Optimized data transfer: Compress messages before sending them to reduce bandwidth usage.
Alternative strategies
While WebSockets provide a solid foundation for building real-time applications, other technologies and approaches can be considered for decentralized chat applications.
- WebRTC data channels: WebRTC (Web Real-Time Communication) provides data channels that allow direct peer-to-peer communication between browsers. This approach eliminates the need for a central server altogether. However, WebRTC requires a more complex signaling mechanism to establish connections.
- Federated networks: Federated networks like Matrix allow users to communicate across different servers, creating a decentralized communication ecosystem. Matrix uses a standard protocol for exchanging data between servers, allowing for interoperability and decentralization.
- Blockchain-based solutions: Blockchain technology can be used to build decentralized chat applications with enhanced security and privacy features. Blockchain-based chat applications use a distributed ledger to store messages, making them tamper-proof and censorship-resistant.
Each approach has its own set of trade-offs, and the best choice depends on the specific requirements of your application.
Real-world scenarios
Decentralized chat applications have a wide range of applications in various industries:
- Secure messaging: Decentralized chat applications can be used to provide secure messaging for journalists, activists, and individuals who need to communicate privately.
- Collaboration tools: Decentralized chat applications can be integrated into collaboration tools to enable real-time communication and collaboration among team members.
- Social networking: Decentralized chat applications can be used to build social networking platforms with enhanced privacy and security features.
- Gaming: Decentralized chat applications can be used to provide in-game communication for multiplayer games.
The possibilities are endless, and decentralized chat applications can empower users with more control over their data and privacy.
Practical tips
Here are some practical tips to keep in mind when building a decentralized chat application:
- Start simple: Begin with a basic implementation and gradually add features as needed.
- Focus on user experience: Design a user-friendly interface that is easy to navigate.
- Test thoroughly: Test your application thoroughly to identify and fix any bugs or issues.
- Gather feedback: Collect feedback from users and use it to improve your application.
- Stay informed: Keep up-to-date with the latest trends and technologies in the field of decentralized communication.
By following these tips, you can create a successful decentralized chat application that meets the needs of your users.
Technical aspects
Let's delve into some technical aspects of building a decentralized chat application with WebSockets.
- WebSocket handshake: The WebSocket handshake is the initial negotiation process between the client and the server. It involves an HTTP upgrade request from the client and a corresponding upgrade response from the server. Understanding the handshake process is crucial for troubleshooting connection issues.
- WebSocket frames: WebSocket messages are transmitted in frames. Each frame contains a header with information about the message, such as the payload length and the opcode (which indicates the type of data being transmitted). Understanding the WebSocket frame format is essential for implementing custom protocols or extensions.
- WebSocket compression: WebSocket compression can be used to reduce the size of messages, improving performance and reducing bandwidth usage. The
permessage-deflate
extension is commonly used for WebSocket compression. - WebSocket security: WebSocket connections should be secured using TLS (Transport Layer Security) to prevent eavesdropping. The
wss://
scheme is used for secure WebSocket connections.
By understanding these technical aspects, you can build more robust and efficient decentralized chat applications.