Build Cheapest High-Performance Serverless WebSocket Solution
Why Serverless?
Nowadays ServerLess is on hype. Everyone wants their service to deploy into ServerLess. Because scaling ServerLess deployment is easy for millions of users. And the bill is fairly simple. Pay ad per you use. BTW, it doesn’t mean that ServerLess don’t have server.
For many years, scaling WebSocket is being too tough. Even for thousand dollar WebSocket server, it crashes when at max 10k concurrent connections. Not every service is not this bad, there are some good WebSocket service out there. But they also expensive and it’s still reasonable.
Requirements
About two weeks ago, I was looking for a scalable WebSocket server solution for a website where it is very general to have 20k concurrent user. The authority wasn’t interested to use long pulling and pusher service anymore. They want their very own solution. They also ready to pay any amount for this as they are very high profile USA client.
After the requirements analysis, what I found is…
-
WebSocket server needs to handle at least 20k concurrent connection and message broadcasting to everyone.
-
A connection can be able to subscribe to multiple channels and receive data from those channels like pusher service.
-
As the WebSocket Protocol doesn’t support CORS protection like rest api, it will need some mechanics to authenticate and restrict connecting client origin.
-
The WebSocket server must be capable of broadcasting at least 2kb of data per minute.
-
The solution must be scalable to 100k concurrent user and so on.
-
Best security practices possible.
-
The solution must be open to customize anytime.
I have a good amount of experience working with the WebSocket and real time stuff. I have decided to go for AWS api gateway WebSocket service.
Planning to use AWS api gateway WebSocket, 4 lambda functions to handle connect, disconnect, broadcast and channel feature.
Not Agreed?
You might tell this solution insane because the lambda service charges based on it’s usages, and the bill will be millions for 20k concurrent WebSocket connections.
Let me clear your confusion. WebSocket connection is not maintained by the lambda. Client connection is going to be with the api gateway service which bill is near $0. Api gateway will proxy the request to lambda for the connect, disconnect and other routes as needed. So the lambda function will be used as needed comparatively very lower to the persistent WebSocket connection maintained by the api gateway service.
To save the channel info and persistent connection ids, started using DynamoDb.
AWS Stack Template
It’s coding time, let’s code the solution. Checkout the below AWS CloudFormation template. I’ll explain later.
Resources:
Connections:
Type: AWS::DynamoDB::Table
Properties:
KeySchema:
- AttributeName: connectionId
KeyType: HASH
- AttributeName: channel
KeyType: RANGE
AttributeDefinitions:
- AttributeName: connectionId
AttributeType: S
- AttributeName: channel
AttributeType: S
ProvisionedThroughput:
ReadCapacityUnits: 5
WriteCapacityUnits: 5
UpdateReplacePolicy: Delete
DeletionPolicy: Delete
ConnectHandlerServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: "2012-10-17"
ManagedPolicyArns:
- Fn::Join:
- ""
- - "arn:"
- Ref: AWS::Partition
- :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
ConnectHandlerServiceRoleDefaultPolicy:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action:
- dynamodb:BatchWriteItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:DescribeTable
Effect: Allow
Resource:
- Fn::GetAtt:
- Connections
- Arn
- Ref: AWS::NoValue
Version: "2012-10-17"
PolicyName: ConnectHandlerServiceRoleDefaultPolicy
Roles:
- Ref: ConnectHandlerServiceRole
ConnectHandler:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: |-
const AWS = require('aws-sdk');
const ddb = new AWS.DynamoDB.DocumentClient();
exports.handler = async function (event, context) {
try {
await ddb
.put({
TableName: process.env.table,
Item: {
connectionId: event.requestContext.connectionId,
channel: 'default'
},
})
.promise();
} catch (err) {
return {
statusCode: 500,
};
}
return {
statusCode: 200,
};
};
Role:
Fn::GetAtt:
- ConnectHandlerServiceRole
- Arn
Environment:
Variables:
table:
Ref: Connections
Handler: index.handler
Runtime: nodejs16.x
DependsOn:
- ConnectHandlerServiceRoleDefaultPolicy
- ConnectHandlerServiceRole
DisconnectHandlerServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: "2012-10-17"
ManagedPolicyArns:
- Fn::Join:
- ""
- - "arn:"
- Ref: AWS::Partition
- :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
DisconnectHandlerServiceRoleDefaultPolicy:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action:
- dynamodb:BatchWriteItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:DescribeTable
- dynamodb:BatchGetItem
- dynamodb:GetRecords
- dynamodb:GetShardIterator
- dynamodb:Query
- dynamodb:GetItem
- dynamodb:Scan
- dynamodb:ConditionCheckItem
Effect: Allow
Resource:
- Fn::GetAtt:
- Connections
- Arn
- Ref: AWS::NoValue
Version: "2012-10-17"
PolicyName: DisconnectHandlerServiceRoleDefaultPolicy
Roles:
- Ref: DisconnectHandlerServiceRole
DisconnectHandler:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: |-
const AWS = require('aws-sdk');
const ddb = new AWS.DynamoDB.DocumentClient();
exports.handler = async function (event, context) {
try {
const rowsResult = await ddb.scan({
TableName: process.env.table,
FilterExpression: 'connectionId = :connectionId',
ExpressionAttributeValues: {
":connectionId": event.requestContext.connectionId
}
}).promise();
const deleteableItems = rowsResult.Items.map(({ connectionId, channel }) => ({
DeleteRequest: {
Key: {
connectionId,
channel
}
}
}))
if (deleteableItems.length) {
await ddb.batchWrite({
RequestItems: {
[process.env.table]: deleteableItems,
}
}).promise();
}
return {
statusCode: 200
}
} catch (err) {
console.log(err)
return {
statusCode: 500,
};
}
};
Role:
Fn::GetAtt:
- DisconnectHandlerServiceRole
- Arn
Environment:
Variables:
table:
Ref: Connections
Handler: index.handler
Runtime: nodejs16.x
DependsOn:
- DisconnectHandlerServiceRoleDefaultPolicy
- DisconnectHandlerServiceRole
SendMessageHandlerServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: "2012-10-17"
ManagedPolicyArns:
- Fn::Join:
- ""
- - "arn:"
- Ref: AWS::Partition
- :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
SendMessageHandlerServiceRoleDefaultPolicy:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action:
- dynamodb:BatchGetItem
- dynamodb:GetRecords
- dynamodb:GetShardIterator
- dynamodb:Query
- dynamodb:GetItem
- dynamodb:Scan
- dynamodb:ConditionCheckItem
- dynamodb:DescribeTable
Effect: Allow
Resource:
- Fn::GetAtt:
- Connections
- Arn
- Ref: AWS::NoValue
Version: "2012-10-17"
PolicyName: SendMessageHandlerServiceRoleDefaultPolicy
Roles:
- Ref: SendMessageHandlerServiceRole
SendMessageHandler:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: |-
const AWS = require('aws-sdk');
const ddb = new AWS.DynamoDB.DocumentClient();
exports.handler = async function (event, context) {
const body = JSON.parse(event.body)
const message = body.message;
const channel = body.channel??'default';
let connections;
try {
connections = await ddb.scan({
TableName: process.env.table,
FilterExpression: 'channel = :channelId',
ExpressionAttributeValues: {
":channelId": channel
}
}).promise();
} catch (err) {
console.log(err)
return {
statusCode: 500,
};
}
const callbackAPI = new AWS.ApiGatewayManagementApi({
apiVersion: '2018-11-29',
endpoint:
event.requestContext.domainName + '/' + event.requestContext.stage,
});
const sendMessages = connections.Items.map(async ({ connectionId }) => {
if (connectionId !== event.requestContext.connectionId) {
try {
await callbackAPI
.postToConnection({ ConnectionId: connectionId, Data: message })
.promise();
} catch (e) {
console.log(e);
}
}
});
try {
await Promise.allSettled(sendMessages);
} catch (e) {
console.log(e);
return {
statusCode: 500,
};
}
return { statusCode: 200 };
};
Role:
Fn::GetAtt:
- SendMessageHandlerServiceRole
- Arn
Environment:
Variables:
table:
Ref: Connections
Handler: index.handler
Runtime: nodejs16.x
DependsOn:
- SendMessageHandlerServiceRoleDefaultPolicy
- SendMessageHandlerServiceRole
JoinChannelHandlerServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: "2012-10-17"
ManagedPolicyArns:
- Fn::Join:
- ""
- - "arn:"
- Ref: AWS::Partition
- :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
JoinChannelHandlerServiceRoleDefaultPolicy:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action:
- dynamodb:BatchWriteItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:DescribeTable
- dynamodb:BatchGetItem
- dynamodb:GetRecords
- dynamodb:GetShardIterator
- dynamodb:Query
- dynamodb:GetItem
- dynamodb:Scan
- dynamodb:ConditionCheckItem
Effect: Allow
Resource:
- Fn::GetAtt:
- Connections
- Arn
- Ref: AWS::NoValue
Version: "2012-10-17"
PolicyName: JoinChannelHandlerServiceRoleDefaultPolicy
Roles:
- Ref: JoinChannelHandlerServiceRole
JoinChannelHandler:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: |-
const AWS = require('aws-sdk');
const ddb = new AWS.DynamoDB.DocumentClient();
exports.handler = async function (event, context) {
try {
const channel = JSON.parse(event.body).channel;
if (! channel.length) throw new Error("Channel Id is required")
await ddb
.put({
TableName: process.env.table,
Item: {
connectionId: event.requestContext.connectionId,
channel
},
})
.promise();
const callbackAPI = new AWS.ApiGatewayManagementApi({
apiVersion: '2018-11-29',
endpoint:
event.requestContext.domainName + '/' + event.requestContext.stage,
});
try {
await callbackAPI.postToConnection({ ConnectionId: event.requestContext.connectionId, Data: JSON.stringify({
channel,
connectionId: event.requestContext.connectionId
}) }).promise();
} catch (e) {}
} catch (err) {
console.log(err)
return {
statusCode: 500,
};
}
return {
statusCode: 200,
};
};
Role:
Fn::GetAtt:
- JoinChannelHandlerServiceRole
- Arn
Environment:
Variables:
table:
Ref: Connections
Handler: index.handler
Runtime: nodejs16.x
DependsOn:
- JoinChannelHandlerServiceRoleDefaultPolicy
- JoinChannelHandlerServiceRole
ManageConnections:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Action: execute-api:ManageConnections
Effect: Allow
Resource:
Fn::Join:
- ""
- - "arn:aws:execute-api:"
- Ref: AWS::Region
- ":"
- Ref: AWS::AccountId
- ":"
- "*/*/POST/@connections/*"
Version: "2012-10-17"
PolicyName: ManageConnections
Roles:
- Ref: SendMessageHandlerServiceRole
- Ref: JoinChannelHandlerServiceRole
Configure & Install
To use this template,
- Create a YAML file named like
api-gateway-websocket.yaml
. Copy the above code block and paste into the YAML file. - Login to your AWS console account. Search for CloudFormation Stacks. Click on Create Stack with new resources (standard).
-
Configure settings like below image
-
Upload the previously saved API Gateway WebSocket YAML file using the uploader shown in arrow 3
-
Provide a stack name and keep the rest of the thing as it is by default. Review and create stack and wait for it to complete.
-
- When the stack will be completed, you will need to create a AWS API Gateway WebSocket service.
- Navigate to API Gateway and click on create api. Click Build button into WebSocket API card.
- Provide the api a name and
request.body.action
in Route selection expression input. - On Add Routes screen, add
$connect
,$disconnect
from predefined routes, and addsendmessage
,joinchannel
keys to create 2 more custom routes. Click Next. - On Attach Integration screen, select lambda for integration type for all the routes. Search and select “ConnectHandler” function for $connect, “DisconnectHandler” for $disconnect, “SendMessageHandler” for
sendmessage
route and “JoinChannelHandler” forjoinchannel
route. - Add a stage like
dev
orproduction
whatever you want to name. - Review and create. After the deployment, you will get a WebSocket endpoint starting with
wss://*
like that. Copy the WebSocket url and try connecting through PieHost. You can also test usingwscat
cli tool. Use this commandwscat -c wss://your-api-gateway-websocket-url
Usages
Let me show you how to make a WebSocket connection with the URL. I’ll use javascript WebSocket connection method in browser and NodeJs WebSocket connection method by using Reconnecting WebSocket using same codebase. Checkout below code.
const airportRws = new ReconnectingWebSocket(
"wss://abcdefg.execute-api.us-east-1.amazonaws.com/dev"
);
airportRws.onopen = (e) => {
// you will be able to join multiple channel and wait for the messages
airportRws.send(
JSON.stringify({
action: "joinchannel",
channel: "friends-channel", // your channel name
})
);
airportRws.send(
JSON.stringify({
action: "joinchannel",
channel: "family-channel",
})
);
airportRws.send(
JSON.stringify({
action: "sendmessage",
channel: "friends-channel",
message: "Hello, everyone",
})
); // this will broadcast your joining message to everyone already connected with this channel
};
airportRws.onclose = (e) => {
console.log("Websocket close", e);
};
airportRws.onerror = (e) => {
console.log("Websocket error", e);
};
airportRws.onmessage = (e) => {
const message = e.data;
const channel = e.channel;
console.log("Channel", parsed.channel)
console.log("Message", parsed)
};
By using above code, you will be able to connect to this WebSocket from browser as well as from NodeJs/NextJs server.
Conclusion
I didn’t include authorization and authentication mechanism into this guide intensionally. Because it will depend how you want to implement those functions. If you need to know how I had implemented those functions, comment down below or directly reach out to me. If you like this post hit the like button, if you unlike it, hit unlike. Feel free to comment and subscribe to my newsletter down below the original post page.