Welcome back to our Real-Time WebSocket Architecture series! In previous parts, we’ve built traditional WebSocket servers and explored scaling strategies. Today, we’re diving into the cutting-edge world of serverless WebSocket architecture using AWS API Gateway and Lambda functions.
Serverless architecture has become a game-changer in 2025, with Node.js being perfectly suited for serverless environments due to its lightweight runtime and event-driven architecture. Platforms like AWS Lambda have optimized their serverless offerings for Node.js, enabling developers to deploy lightweight functions that scale automatically.
Why Serverless WebSockets?
With serverless architecture, developers can focus solely on writing code that responds to events or triggers, while the cloud service provider takes care of resource allocation, scaling, and maintenance. This approach offers several compelling advantages:
- Pay-per-use billing – Only pay for actual WebSocket connections and message processing
- Automatic scaling – Handle traffic spikes without infrastructure management
- Zero server maintenance – No more patching, updating, or monitoring servers
- Global edge deployment – Reduced latency with AWS edge locations
graph TB Client[WebSocket Client] --> API[API Gateway WebSocket] API --> Lambda1[Connect Handler] API --> Lambda2[Message Handler] API --> Lambda3[Disconnect Handler] Lambda1 --> DDB[(DynamoDBConnection Store)] Lambda2 --> DDB Lambda3 --> DDB Lambda2 --> SQS[SQS Queue] SQS --> Lambda4[Broadcast Handler] Lambda4 --> API Lambda4 --> DDB
Architecture Overview
Our serverless WebSocket architecture consists of four main components:
- API Gateway WebSocket API – Manages WebSocket connections
- Lambda Functions – Handle connect, disconnect, and message events
- DynamoDB – Stores connection metadata and application state
- SQS – Handles message broadcasting and queuing
Setting Up the Project
Let’s start by creating our serverless WebSocket chat application using the Serverless Framework:
# Install Serverless Framework globally
npm install -g serverless
# Create new project
serverless create --template aws-nodejs --path serverless-websocket-chat
cd serverless-websocket-chat
# Install dependencies
npm init -y
npm install aws-sdk uuid
Create the serverless.yml
configuration:
service: serverless-websocket-chat
provider:
name: aws
runtime: nodejs18.x
region: us-east-1
environment:
CONNECTIONS_TABLE: ${self:service}-connections-${self:provider.stage}
MESSAGES_QUEUE: ${self:service}-messages-${self:provider.stage}
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:GetItem
- dynamodb:DeleteItem
- dynamodb:Scan
Resource:
- Fn::GetAtt: [ConnectionsTable, Arn]
- Effect: Allow
Action:
- sqs:SendMessage
- sqs:ReceiveMessage
- sqs:DeleteMessage
Resource:
- Fn::GetAtt: [MessagesQueue, Arn]
- Effect: Allow
Action:
- execute-api:PostToConnection
Resource: "*"
functions:
connectHandler:
handler: handlers/connect.handler
events:
- websocket:
route: $connect
disconnectHandler:
handler: handlers/disconnect.handler
events:
- websocket:
route: $disconnect
messageHandler:
handler: handlers/message.handler
events:
- websocket:
route: message
broadcastHandler:
handler: handlers/broadcast.handler
events:
- sqs:
arn:
Fn::GetAtt: [MessagesQueue, Arn]
resources:
Resources:
ConnectionsTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: ${self:provider.environment.CONNECTIONS_TABLE}
AttributeDefinitions:
- AttributeName: connectionId
AttributeType: S
KeySchema:
- AttributeName: connectionId
KeyType: HASH
BillingMode: PAY_PER_REQUEST
MessagesQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: ${self:provider.environment.MESSAGES_QUEUE}
Connection Handler
Create handlers/connect.js
to manage new WebSocket connections:
const AWS = require('aws-sdk');
const { v4: uuidv4 } = require('uuid');
const dynamodb = new AWS.DynamoDB.DocumentClient();
exports.handler = async (event) => {
const { connectionId } = event.requestContext;
const { username } = event.queryStringParameters || { username: 'Anonymous' };
const timestamp = new Date().toISOString();
try {
// Store connection in DynamoDB
await dynamodb.put({
TableName: process.env.CONNECTIONS_TABLE,
Item: {
connectionId,
username,
connectedAt: timestamp,
userId: uuidv4()
}
}).promise();
console.log(`User ${username} connected with ID: ${connectionId}`);
return {
statusCode: 200,
body: JSON.stringify({
message: 'Connected successfully',
connectionId,
username
})
};
} catch (error) {
console.error('Connection error:', error);
return {
statusCode: 500,
body: JSON.stringify({
message: 'Failed to connect',
error: error.message
})
};
}
};
Message Handler
Create handlers/message.js
to process incoming messages:
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
const sqs = new AWS.SQS();
exports.handler = async (event) => {
const { connectionId } = event.requestContext;
const { body } = event;
try {
const messageData = JSON.parse(body);
const { message, type = 'chat' } = messageData;
// Get user info from DynamoDB
const connection = await dynamodb.get({
TableName: process.env.CONNECTIONS_TABLE,
Key: { connectionId }
}).promise();
if (!connection.Item) {
return {
statusCode: 404,
body: JSON.stringify({ message: 'Connection not found' })
};
}
const { username, userId } = connection.Item;
// Prepare message for broadcasting
const broadcastMessage = {
type,
message,
username,
userId,
timestamp: new Date().toISOString(),
senderConnectionId: connectionId
};
// Send to SQS for broadcasting
await sqs.sendMessage({
QueueUrl: `https://sqs.${process.env.AWS_REGION}.amazonaws.com/${process.env.AWS_ACCOUNT_ID}/${process.env.MESSAGES_QUEUE}`,
MessageBody: JSON.stringify(broadcastMessage)
}).promise();
return {
statusCode: 200,
body: JSON.stringify({ message: 'Message sent successfully' })
};
} catch (error) {
console.error('Message handling error:', error);
return {
statusCode: 500,
body: JSON.stringify({
message: 'Failed to send message',
error: error.message
})
};
}
};
Broadcast Handler
Create handlers/broadcast.js
to send messages to all connected clients:
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
const apiGateway = new AWS.ApiGatewayManagementApi({
endpoint: process.env.WEBSOCKET_API_ENDPOINT
});
exports.handler = async (event) => {
try {
for (const record of event.Records) {
const messageData = JSON.parse(record.body);
// Get all active connections
const connections = await dynamodb.scan({
TableName: process.env.CONNECTIONS_TABLE
}).promise();
// Broadcast to all connections except sender
const broadcastPromises = connections.Items
.filter(conn => conn.connectionId !== messageData.senderConnectionId)
.map(async (connection) => {
try {
await apiGateway.postToConnection({
ConnectionId: connection.connectionId,
Data: JSON.stringify({
type: 'message',
data: messageData
})
}).promise();
} catch (error) {
// Remove stale connections
if (error.statusCode === 410) {
await dynamodb.delete({
TableName: process.env.CONNECTIONS_TABLE,
Key: { connectionId: connection.connectionId }
}).promise();
}
}
});
await Promise.all(broadcastPromises);
}
return { statusCode: 200 };
} catch (error) {
console.error('Broadcast error:', error);
return { statusCode: 500 };
}
};
Disconnect Handler
Create handlers/disconnect.js
to clean up connections:
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
exports.handler = async (event) => {
const { connectionId } = event.requestContext;
try {
// Get user info before deletion
const connection = await dynamodb.get({
TableName: process.env.CONNECTIONS_TABLE,
Key: { connectionId }
}).promise();
// Remove connection from DynamoDB
await dynamodb.delete({
TableName: process.env.CONNECTIONS_TABLE,
Key: { connectionId }
}).promise();
if (connection.Item) {
console.log(`User ${connection.Item.username} disconnected`);
}
return {
statusCode: 200,
body: JSON.stringify({ message: 'Disconnected successfully' })
};
} catch (error) {
console.error('Disconnect error:', error);
return {
statusCode: 500,
body: JSON.stringify({
message: 'Failed to disconnect',
error: error.message
})
};
}
};
Client Implementation
Create a simple HTML client to test our serverless WebSocket chat:
<!DOCTYPE html>
<html>
<head>
<title>Serverless WebSocket Chat</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; }
#messages { border: 1px solid #ccc; height: 300px; overflow-y: scroll; padding: 10px; margin: 10px 0; }
input, button { padding: 10px; margin: 5px; }
.message { margin: 5px 0; padding: 5px; background: #f0f0f0; border-radius: 5px; }
.own-message { background: #007bff; color: white; text-align: right; }
</style>
</head>
<body>
<h1>Serverless WebSocket Chat</h1>
<div>
<input type="text" id="username" placeholder="Enter username" />
<button id="connect">Connect</button>
<button id="disconnect" disabled>Disconnect</button>
</div>
<div id="messages"></div>
<div>
<input type="text" id="messageInput" placeholder="Type a message..." disabled />
<button id="send" disabled>Send</button>
</div>
<script>
let ws = null;
let username = '';
const messagesDiv = document.getElementById('messages');
const usernameInput = document.getElementById('username');
const messageInput = document.getElementById('messageInput');
const connectBtn = document.getElementById('connect');
const disconnectBtn = document.getElementById('disconnect');
const sendBtn = document.getElementById('send');
connectBtn.addEventListener('click', connect);
disconnectBtn.addEventListener('click', disconnect);
sendBtn.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
function connect() {
username = usernameInput.value.trim();
if (!username) {
alert('Please enter a username');
return;
}
// Replace with your actual WebSocket API endpoint
const wsUrl = `wss://your-api-id.execute-api.us-east-1.amazonaws.com/dev?username=${encodeURIComponent(username)}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
addMessage('Connected to serverless chat!', 'system');
connectBtn.disabled = true;
disconnectBtn.disabled = false;
messageInput.disabled = false;
sendBtn.disabled = false;
usernameInput.disabled = true;
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'message') {
const msg = data.data;
addMessage(`${msg.username}: ${msg.message}`, 'received');
}
};
ws.onclose = () => {
addMessage('Disconnected from chat', 'system');
resetUI();
};
ws.onerror = (error) => {
addMessage(`Connection error: ${error.message}`, 'error');
resetUI();
};
}
function disconnect() {
if (ws) {
ws.close();
}
}
function sendMessage() {
const message = messageInput.value.trim();
if (!message || !ws) return;
ws.send(JSON.stringify({
action: 'message',
message: message,
type: 'chat'
}));
addMessage(`You: ${message}`, 'sent');
messageInput.value = '';
}
function addMessage(text, type) {
const div = document.createElement('div');
div.className = `message ${type}`;
div.textContent = text;
messagesDiv.appendChild(div);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function resetUI() {
connectBtn.disabled = false;
disconnectBtn.disabled = true;
messageInput.disabled = true;
sendBtn.disabled = true;
usernameInput.disabled = false;
ws = null;
}
</script>
</body>
</html>
Deployment and Testing
Deploy your serverless WebSocket application:
# Deploy to AWS
serverless deploy
# Output will show your WebSocket API endpoint
# Service Information
# service: serverless-websocket-chat
# stage: dev
# region: us-east-1
# stack: serverless-websocket-chat-dev
# api_endpoint: wss://abc123.execute-api.us-east-1.amazonaws.com/dev
Update the WebSocket URL in your HTML client with the endpoint from the deployment output.
Advanced Features and Optimizations
Connection Management
One of the main drawbacks of serverless is cold starts — the delay when a function is invoked after a period of inactivity. To optimize for WebSocket connections:
- Use provisioned concurrency for critical functions
- Implement connection pooling in DynamoDB
- Add TTL (Time To Live) for automatic cleanup
Monitoring and Logging
Use AWS CloudWatch for real-time logging and monitoring by adding structured logging to your Lambda functions:
// Add to each handler
const logger = {
info: (message, meta = {}) => {
console.log(JSON.stringify({
level: 'info',
message,
timestamp: new Date().toISOString(),
...meta
}));
},
error: (message, error, meta = {}) => {
console.error(JSON.stringify({
level: 'error',
message,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
...meta
}));
}
};
Cost Analysis and Benefits
With serverless, businesses pay only for the actual execution time of code, with no upfront costs or fixed infrastructure expenses. For a typical chat application:
- API Gateway WebSocket: $1.00 per million connection minutes
- Lambda invocations: $0.20 per million requests
- DynamoDB: Pay-per-request pricing
- SQS: $0.40 per million requests
Compared to running dedicated EC2 instances, serverless can provide 60-80% cost savings for variable workloads.
What’s Next?
In **Part 8**, we’ll explore **Edge Computing with WebSockets** and how to deploy WebSocket applications using Cloudflare Workers and other edge computing platforms for ultra-low latency global applications.
Key topics we’ll cover:
- Edge WebSocket implementation
- Global state synchronization
- Latency optimization techniques
- Performance benchmarking
Questions or feedback? Drop a comment below or connect with me on social media. Don’t forget to subscribe for the next part of our WebSocket architecture series!