Real-Time Chat with AWS WebSocket API
Build a serverless real-time chat system with API Gateway, Lambda, and DynamoDB.
Introduction
30 Second Summary
Every time you send a message in Slack or Discord, it appears on your teammate's screen in real time. That instant delivery relies on persistent WebSocket connections between clients and a server.
In this project, you will build a real-time chat system on AWS using API Gateway, Lambda, and DynamoDB. You will hit the broadcast-everything problem and solve it by adding chat room isolation.
What You'll Build
Picture opening three terminal windows, typing a message in one, and watching it pop up in the other two instantly.
By the end of this project, you'll have:
- A real-time messaging system where you send a message from one terminal and it appears instantly on every other connected client.
- Chat room isolation where joining "general" means you only see messages from "general", not from "engineering".
- Automatic stale-connection cleanup that silently removes dead clients so your message fan-out never breaks.
- An optional Secret Mission to push your skills further.
Are there any prerequisites?
You will need an AWS account with the AWS CLI configured and Node.js installed on your machine. If you don't have these set up yet, complete those before starting this project.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to commit to the goal gives you a mental model to check your progress against as you work through each step.
Set Up Your Tools
Your chat system communicates over persistent WebSocket connections. Unlike regular HTTP requests that end after a single response, a WebSocket connection stays open so both sides can send messages at any time.
To test these connections from your terminal, you need a dedicated WebSocket client called wscat. In this step, you'll install it using npm (which comes with your Node.js installation) and verify it's ready to go.
In this step, get ready to:
- Install wscat, a command-line WebSocket client.
- Verify wscat is working by checking its version number.
Install wscat
wscat is a lightweight command-line tool that connects to WebSocket endpoints, sends messages, and displays incoming data right in your terminal. You'll use it as the "chat window" for every test in this project.
- Add wscat to your global npm packages by running this command:
npm install -g wscat
After the install completes, you'll see a summary line showing the package was added.
Seeing an EACCES permission error?
On macOS or Linux, npm may not have permission to write to the global packages directory. Close your terminal and reopen it, then try the install again with sudo in front:
sudo npm install -g wscat
If you keep hitting permission issues, the npm docs recommend reinstalling Node.js with a version manager like nvm to avoid permission problems entirely.
Help me fix my npm global install permission error
Verify your installation
Before you run the next command, what do you expect to see if wscat installed correctly?
- Check that wscat is available by running:
wscat --version
✔️ I see a version number
You should see something like 6.1.0 printed in your terminal. The exact number may differ, and that's fine. wscat is installed and ready to test WebSocket connections.
ⓧ Command not found
Your terminal cannot find the wscat binary. Try these fixes in order:
- Close your terminal completely and reopen it. New global packages sometimes need a fresh terminal session to appear in your PATH.
- Run npm install -g wscat again and watch for any error messages in the output. If you see a permission error, check the troubleshooting callout above.
- Confirm Node.js is working by running node --version. If that also fails, Node.js may not be installed or not on your PATH.
Help me fix wscat not being found after npm install
Your WebSocket testing tool is locked in. Next, you'll create the backend resources that power your chat system: a DynamoDB table, Lambda functions, and the IAM role that ties them together.
Create the Backend Resources
Your tools are ready. Now you need the backend infrastructure that powers the chat system before a single message can flow.
A real-time chat system needs three things: a database to track who is connected, functions to handle WebSocket events, and permissions tying them together. This step creates all three.
In this step, get ready to:
- Create a DynamoDB table to store active connections.
- Create an IAM role that lets Lambda access DynamoDB and the WebSocket API.
- Create three Lambda functions for connect, disconnect, and send message events.
Create the DynamoDB table
Every time a user connects to your chat system, you need to remember their connection ID so you can send them messages later. DynamoDB is a fast key-value database that is perfect for this.
- Sign in to the AWS Management Console at https://console.aws.amazon.com.
- In the top search bar, type DynamoDB and select it from the results.
- In the left navigation pane, select Tables.
- Click Create table.
- For Table name, enter ChatConnections.
- For Partition key, enter connectionId and leave the type set to String.
- For Table settings, keep the default selection of Default settings.
- Click Create table.
Why default settings?
Default settings use provisioned capacity mode, which qualifies for the DynamoDB always-free tier (25 GB storage, 25 read and 25 write capacity units per month). On-demand mode does not qualify for this free tier.
Wait a few seconds for the table status to change from Creating to Active. You can click the table name to check its status.
Table stuck in Creating?
DynamoDB tables usually become active within a few seconds. If the status does not change, refresh the page. Help me troubleshoot my DynamoDB table creation.
Create the IAM role
Your Lambda functions need permission to read and write to DynamoDB, and to push messages back through the WebSocket API. An IAM role bundles these permissions into a single identity that all three functions share.
- In the top search bar, type IAM and select it from the results.
- In the left navigation pane, select Roles.
- Click Create role.
- For Trusted entity type, select AWS service.
- For Service or use case, select Lambda.
- Click Next.
- In the Permissions policies search box, type AWSLambdaBasicExecutionRole.
- Select the checkbox next to AWSLambdaBasicExecutionRole.
What is AWSLambdaBasicExecutionRole?
This managed policy gives Lambda permission to write logs to CloudWatch. Every Lambda function needs this at minimum. You will add the DynamoDB and WebSocket permissions as a separate inline policy after creating the role.
- Click Next.
- For Role name, enter ChatLambdaRole.
- Click Create role.
The role now has basic logging permissions. Next, add the inline policy for DynamoDB and WebSocket access.
- In the Roles list, click the name ChatLambdaRole to open it.
- Select the Permissions tab.
- Click Add permissions, then select Create inline policy.
- Select the JSON tab in the policy editor.
- Delete any existing content, then paste the following policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:Scan"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "execute-api:ManageConnections",
"Resource": "*"
}
]
}
What does this policy do?
The first statement grants the three DynamoDB actions your functions need: dynamodb:PutItem to store connections, dynamodb:DeleteItem to remove them, and dynamodb:Scan to list all connections for broadcasting.
The second statement grants execute-api:ManageConnections. This lets Lambda push messages back through the WebSocket API to connected clients.
- Click Next.
- For Policy name, enter ChatInlinePolicy.
- Click Create policy.
You should now see two policies attached to the ChatLambdaRole on the Permissions tab: the managed AWSLambdaBasicExecutionRole and your new inline policy.
Don't see the inline policy?
Refresh the role's page. Confirm you are viewing the Permissions tab, not Trust relationships. Both policies should be listed. Help me troubleshoot my IAM role permissions.
Create the Lambda functions
With the database and permissions in place, you can now create the three Lambda functions that handle WebSocket lifecycle events: connecting, disconnecting, and sending messages.
- In the top search bar, type Lambda and select it from the results.
- Click Create function.
- Select Author from scratch.
- For Function name, enter ChatConnect.
- For Runtime, select Python 3.13.
- Expand Change default execution role.
- Select Use an existing role.
- In the Existing role dropdown, select ChatLambdaRole.
- Click Create function.
Lambda opens the function's code editor with a default lambda_function.py file.
- Delete all the default code in the editor, then paste the following:
import boto3
import os
# Connect to DynamoDB
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['CONNECTIONS_TABLE'])
def lambda_handler(event, context):
# Extract the unique connection ID from the WebSocket event
connection_id = event['requestContext']['connectionId']
# Store this connection in the table
table.put_item(
Item={
'connectionId': connection_id
}
)
return {'statusCode': 200}
What does this code do?
When a client opens a WebSocket connection, API Gateway fires a $connect event. This function reads the connectionId from that event and saves it to the ChatConnections table. That connection ID is how you address the client later when broadcasting messages.
- In the DEPLOY section above the code editor, click Deploy.
Now add the environment variable that tells the function which DynamoDB table to use.
- Click the Configuration tab.
- Select Environment variables in the left sidebar.
- Click Edit.
- Click Add environment variable.
- For Key, enter CONNECTIONS_TABLE.
- For Value, enter ChatConnections.
- Click Save.
Why use an environment variable?
The code reads the table name from os.environ['CONNECTIONS_TABLE'] instead of hardcoding it. This keeps the function portable. If you ever rename the table, you only update the environment variable instead of editing three functions.
The first function is done. Now create the second function using the same process.
- Navigate back to the Functions list by clicking Functions in the left sidebar.
- Click Create function.
- Select Author from scratch.
- For Function name, enter ChatDisconnect.
- For Runtime, select Python 3.13.
- Expand Change default execution role, select Use an existing role, and choose ChatLambdaRole.
- Click Create function.
- Delete the default code and paste the following:
import boto3
import os
# Connect to DynamoDB
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['CONNECTIONS_TABLE'])
def lambda_handler(event, context):
# Extract the connection ID of the client that disconnected
connection_id = event['requestContext']['connectionId']
# Remove this connection from the table
table.delete_item(
Key={'connectionId': connection_id}
)
return {'statusCode': 200}
What does this code do?
When a client closes their WebSocket connection, API Gateway fires a $disconnect event. This function removes the connectionId from the table so you stop trying to send messages to a client that no longer exists.
- Click Deploy.
- Add the environment variable following the same steps as before: Configuration tab, Environment variables, Edit, Add environment variable.
- Set Key to CONNECTIONS_TABLE and Value to ChatConnections.
- Click Save.
Two down, one to go. The third function is the most important: it broadcasts messages to everyone who is connected.
- Navigate back to the Functions list.
- Click Create function.
- Select Author from scratch.
- For Function name, enter ChatSendMessage.
- For Runtime, select Python 3.13.
- Expand Change default execution role, select Use an existing role, and choose ChatLambdaRole.
- Click Create function.
- Delete the default code and paste the following setup code:
import boto3
import os
import json
# Connect to DynamoDB
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['CONNECTIONS_TABLE'])
def lambda_handler(event, context):
# Extract connection details from the WebSocket event
domain = event['requestContext']['domainName']
stage = event['requestContext']['stage']
sender_id = event['requestContext']['connectionId']
# Create a client to send messages back through the WebSocket API
apigw = boto3.client(
'apigatewaymanagementapi',
endpoint_url=f'https://{domain}/{stage}'
)
# Parse the incoming message from the request body
body = json.loads(event['body'])
message = body.get('message', '')
What does this code do so far?
This sets up everything the function needs before broadcasting. It reads the WebSocket domain and stage to construct the callback URL, then creates an apigatewaymanagementapi client that can push data to connected clients. Finally, it parses the message from the incoming JSON body.
- Place your cursor at the end of the code you just pasted, press Enter to start a new blank line, then paste the following code directly below it:
# Get all active connections from DynamoDB
connections = table.scan()['Items']
# Build the message payload to broadcast
payload = json.dumps({
'message': message,
'sender': sender_id
}).encode('utf-8')
# Send the message to every connected client except the sender
for conn in connections:
cid = conn['connectionId']
if cid == sender_id:
continue
try:
apigw.post_to_connection(ConnectionId=cid, Data=payload)
except apigw.exceptions.GoneException:
# Clean up stale connections that no longer exist
table.delete_item(Key={'connectionId': cid})
return {'statusCode': 200}
What does the fan-out loop do?
The function scans every connection from the table, then loops through each one. It skips the sender (you do not want to echo your own message back). For each other client, it calls post_to_connection to push the message through the WebSocket API.
The GoneException catch is a critical production pattern. If a client disconnected unexpectedly (closed their laptop, lost internet), the connection ID is stale. Instead of crashing, the function deletes the dead entry and moves on.
- Click Deploy.
- Add the environment variable: Configuration tab, Environment variables, Edit, Add environment variable.
- Set Key to CONNECTIONS_TABLE and Value to ChatConnections.
- Click Save.
Why does this function scan the whole table?
Right now, table.scan() returns every connection. This means every message goes to every connected client. That works for a basic broadcast system, but it means there is no concept of chat rooms. A message in one conversation leaks into every other conversation. You will fix this limitation in Step 4.
✔️ Awesome, I've got everything!
Make sure you have clicked Deploy and saved the environment variable on all three functions.
ⓧ I'd like to double check the full code
Scroll up and confirm each function's code matches what you pasted. The three functions are ChatConnect (stores the connection ID), ChatDisconnect (removes the connection ID), and ChatSendMessage (scans all connections and broadcasts). Each function should have a CONNECTIONS_TABLE environment variable set to ChatConnections.
Before you move on, do a quick check. How many functions do you expect to see in the Lambda console?
- Navigate back to the Functions list in the Lambda console.
You should see all three functions listed: ChatConnect, ChatDisconnect, and ChatSendMessage. Each one shows python3.13 as the runtime.
Missing a function?
Check the region selector in the top right of the console. All three functions must be in the same region. If you created one in a different region, it will not appear in the list. Help me find my missing Lambda function.
The foundation is set. You have a database, permissions, and three functions ready to handle the WebSocket lifecycle. Next, you will wire these functions to a WebSocket API and send your first real-time message.
Wire Up the WebSocket API and Send Your First Message
You have a DynamoDB table tracking connections, three Lambda functions to handle events, and an IAM role tying them together. But nothing connects them to the outside world yet.
The missing piece is a WebSocket API in API Gateway. This is the front door that accepts client connections, reads incoming messages, and routes each one to the right Lambda function. By the end of this step, you will see real-time messages flowing between terminal windows.
In this step, get ready to:
- Create a WebSocket API in API Gateway with routes wired to your Lambda functions.
- Deploy the API and grab your WebSocket URL.
- Send your first real-time message between two terminals.
Create a WebSocket API in API Gateway
A WebSocket API uses a route selection expression to decide which Lambda function should handle each incoming message. You will set this expression to $request.body.action, which tells API Gateway to look at the action field in the JSON body and match it to a route.
- Return to the AWS Management Console and navigate to the API Gateway service.
- Click Create API.
- Under WebSocket API, click Build.
- For API name, enter ChatWebSocketAPI.
- For IP address type, select IPv4.
- For Route selection expression, enter $request.body.action.
- Click Next.
What is a route selection expression?
When a connected client sends a JSON message like {"action": "sendmessage", "message": "hello!"}, API Gateway evaluates the route selection expression against the message body. Because the expression is $request.body.action, it extracts the value sendmessage and routes the message to the matching route.
Now you need to add routes. Each route maps to one of the Lambda functions you created in the previous step.
- Under Predefined routes, click Add $connect.
- Click Add $disconnect.
- Under Custom routes, click Add custom route.
- For Route key, enter sendmessage.
- Click Next.
Next, you will attach your Lambda functions to each route.
- Under Attach integrations, for the $connect route, set Integration type to Lambda.
- For Lambda, select ChatConnect.
- For the $disconnect route, set Integration type to Lambda and select ChatDisconnect.
- For the sendmessage route, set Integration type to Lambda and select ChatSendMessage.
- Click Next.
What about Lambda permissions?
When you attach a Lambda function through the API Gateway console, API Gateway automatically adds a resource-based policy to each function granting itself permission to invoke it. You do not need to configure this manually.
Deploy the API to a production stage
An API that exists in API Gateway is not callable until you deploy it to a stage. A stage is a named snapshot of your API that clients can connect to.
- On the review screen, confirm that API Gateway will create a stage named production and automatically deploy your API to it.
- Click Next.
- Click Create and deploy.
Your WebSocket API is now live. You need to grab the connection URL so wscat can connect to it.
- In the left navigation pane, click Stages.
- Click production.
- Copy the WebSocket URL displayed on the page. It looks like wss://abcdef123.execute-api.us-east-1.amazonaws.com/production.
Keep this URL handy
You will use the WebSocket URL in every terminal connection for the rest of this project. Paste it somewhere easy to reach.
Send your first real-time message
Everything is wired up. Time to see real-time messaging in action by connecting two terminal clients and sending a message between them.
- Open a terminal window. Connect to your WebSocket API by running this command:
wscat -c [[WEBSOCKET_URL="your WebSocket URL from the Stages page"]]
You should see the following output:
Connected (press CTRL+C to quit)
What just happened?
When wscat connected, API Gateway invoked the $connect route, which triggered the ChatConnect Lambda function. That function stored your connection ID in the ChatConnections DynamoDB table.
- Open a second terminal window and connect with the same command:
wscat -c [[WEBSOCKET_URL="your WebSocket URL from the Stages page"]]
You now have two connected clients. The DynamoDB table holds both connection IDs.
- In the first terminal, type the following message and press Enter:
{"action": "sendmessage", "message": "hello!"}
Switch to your second terminal.
You should see a JSON message appear containing your hello! text, the sender's connection ID, and a confirmation that the sendmessage route broadcast it.
How does fan-out work here?
The ChatSendMessage function scans every row in the ChatConnections table to get all active connection IDs. It then loops through them and calls post_to_connection on each one, skipping the sender. This is the fan-out pattern. Every connected client receives the message.
If a connection is stale (the client already disconnected without triggering $disconnect), the API Gateway Management API throws a GoneException. The function catches it and deletes the dead connection from DynamoDB immediately.
Not seeing the message in Terminal 2?
- Make sure the action field in your JSON is exactly sendmessage (all lowercase, one word). This must match the custom route key you created.
- Double-check that both terminals show Connected (press CTRL+C to quit) before sending. If a connection dropped, reconnect with the same wscat command.
- Confirm your ChatSendMessage function has the CONNECTIONS_TABLE environment variable set to ChatConnections. A missing variable causes a silent failure.
Still stuck? Help me debug why my WebSocket message is not arriving in the other terminal.
Now for the critical test. Before you connect a third client, think about this: what do you expect will happen when three clients are connected and one sends a message? Will the message go to just one of the others, or to all of them?
- Open a third terminal window and connect with the same command:
wscat -c [[WEBSOCKET_URL="your WebSocket URL from the Stages page"]]
- In the third terminal, type and send:
{"action": "sendmessage", "message": "can everyone see this?"}
Switch to Terminal 1 and Terminal 2. Both of them received the message.
Now try sending a message from Terminal 1. Both Terminal 2 and Terminal 3 receive it. Every connected client sees every message, no matter who sent it.
This is a problem
Broadcasting every message to every connected user is useless for a real chat app. Imagine if Slack sent every message in every channel to every user. That is what your system does right now.
The ChatSendMessage function calls table.scan() which returns all connections in the table. There is no concept of rooms, channels, or any grouping. This is the fan-out scoping problem, and you will fix it in the next step.
Real-time messaging is working. You can send a message from one terminal and see it pop up instantly in every other connected terminal. In the next step, you will add chat room isolation so messages only reach users in the same room.
Add Chat Rooms
In the last step, you sent your first real-time message and watched it arrive instantly in another terminal. But you also spotted the problem: every connected client receives every message.
Broadcasting everything to everyone is useless for a real chat application. A conversation in general should not leak into engineering. This is the core fan-out scoping problem in system design, and you will solve it using a DynamoDB Global Secondary Index.
In this step, get ready to:
- Add a Global Secondary Index to the ChatConnections table for room-based queries.
- Update the Lambda functions and IAM role to store and filter by chat room.
- Verify that messages only reach clients in the same room.
Add a Global Secondary Index to the ChatConnections table
Right now, the ChatSendMessage function scans the entire ChatConnections table to find recipients. To filter by room, you need a way to query connections by a roomId attribute. A Global Secondary Index lets you do exactly that.
- In the AWS Console, navigate to the DynamoDB service.
- Click Tables in the left sidebar.
- Click on the ChatConnections table.
- Click the Indexes tab.
What is a Global Secondary Index?
Your table's primary key is connectionId, which is great for looking up a single connection. A Global Secondary Index (GSI) creates a second lookup path using a different attribute as the key.
By creating a GSI on roomId, you can query DynamoDB for all connections in a specific room without scanning every item in the table. This is the difference between flipping through every page of a book and jumping straight to the index entry you need.
- Click Create index.
- Enter roomId in the Partition key field.
- Select String as the type.
- In the Index name field, enter RoomIndex.
- Leave Projected attributes set to All.
- Click Create index.
DynamoDB builds the index in the background. Wait until the RoomIndex status changes to Active before continuing. This usually takes a minute or two.
Index stuck in Creating?
Refresh the page and check the Indexes tab again. GSI creation can take a few minutes depending on the table size. If it stays in Creating for more than five minutes, confirm the table is using provisioned capacity mode (the default) and that you have not exceeded the GSI limit of 20 per table.
Help me debug my DynamoDB GSI
Update the IAM policy and Lambda functions
The GSI is ready, but the Lambda functions do not know about rooms yet. You need to grant two new permissions to the IAM role, then update the ChatConnect and ChatSendMessage functions to use them.
- In the AWS Console, navigate to the IAM service.
- Click Roles in the left sidebar.
- Search for and click on ChatLambdaRole.
- Under Permissions policies, expand the inline policy you created earlier and click Edit.
- Find the Action array in the JSON. It currently looks like this:
"Action": [
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:Scan",
"execute-api:ManageConnections"
]
- Add dynamodb:Query and dynamodb:GetItem to the array so it looks like this:
"Action": [
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:Scan",
"dynamodb:Query",
"dynamodb:GetItem",
"execute-api:ManageConnections"
]
- Click Save changes.
Why two new permissions?
The dynamodb:GetItem permission lets the ChatSendMessage function look up the sender's room before broadcasting. The dynamodb:Query permission lets it query the RoomIndex GSI to find only the connections in that room.
Now update the ChatConnect function to store the room each client joins.
- Navigate to the Lambda service in the AWS Console.
- Click on the ChatConnect function.
- Replace the entire contents of lambda_function.py with this updated code:
import boto3
import os
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['CONNECTIONS_TABLE'])
def lambda_handler(event, context):
connection_id = event['requestContext']['connectionId']
# Read the room from the query string, default to 'general'
query_params = event.get('queryStringParameters') or {}
room_id = query_params.get('room', 'general')
table.put_item(
Item={
'connectionId': connection_id,
'roomId': room_id
}
)
return {'statusCode': 200}
What does this code do?
- event.get('queryStringParameters') reads the query string parameters from the WebSocket connection URL. The or {} handles the case where no parameters are provided.
- query_params.get('room', 'general') extracts the room value. If the client did not specify one, it defaults to general.
- The put_item call now stores both connectionId and roomId in DynamoDB. This is what the RoomIndex GSI indexes on.
- Click Deploy to save and deploy the function.
✔️ Awesome, I've got everything!
Great. Make sure you clicked Deploy and see the Changes deployed banner at the top of the function page.
ⓧ I'd like to double check the full code
Your ChatConnect/lambda_function.py should look exactly like this:
import boto3
import os
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['CONNECTIONS_TABLE'])
def lambda_handler(event, context):
connection_id = event['requestContext']['connectionId']
query_params = event.get('queryStringParameters') or {}
room_id = query_params.get('room', 'general')
table.put_item(
Item={
'connectionId': connection_id,
'roomId': room_id
}
)
return {'statusCode': 200}
Before touching ChatSendMessage, verify that ChatConnect is storing the roomId correctly.
- Open a terminal and connect to your WebSocket API with a room parameter by running this command:
wscat -c "wss://[[WS_API_ID="your API ID"]].execute-api.[[AWS_REGION="your AWS region"]].amazonaws.com/production?room=testroom"
- You should see Connected (press CTRL+C to quit).
- Switch to the DynamoDB console and click on the ChatConnections table.
- Click Explore table items.
- Confirm that the new item has both a connectionId and a roomId value of testroom.
- Go back to the terminal and press Ctrl+C to disconnect.
Don't see the roomId column?
Make sure you clicked Deploy on the ChatConnect function page (not just Save). Also confirm you included ?room=testroom at the end of the URL when connecting with wscat. Click Run or refresh the items scan in the DynamoDB console to see the latest data.
Help me debug my ChatConnect roomId
Now update the ChatSendMessage function to query by room instead of scanning the entire table.
- In the Lambda console, click on the ChatSendMessage function.
- In lambda_function.py, find this import line at the top:
import boto3
- Add this new import line directly below it:
from boto3.dynamodb.conditions import Key
- Next, find this block of code inside the lambda_handler function:
connections = table.scan()['Items']
payload = json.dumps({
'message': message,
'sender': sender_id
}).encode('utf-8')
- Replace that entire block with this code:
# Look up which room the sender belongs to
sender_item = table.get_item(Key={'connectionId': sender_id})
room_id = sender_item['Item'].get('roomId', 'general')
# Query the GSI for connections in the same room
connections = table.query(
IndexName='RoomIndex',
KeyConditionExpression=Key('roomId').eq(room_id)
)['Items']
payload = json.dumps({
'message': message,
'sender': sender_id,
'room': room_id
}).encode('utf-8')
What changed here?
- table.get_item(Key={'connectionId': sender_id}) fetches the sender's record to find which room they belong to.
- table.query(IndexName='RoomIndex', ...) replaces table.scan(). Instead of pulling every connection in the table, it only retrieves connections in the sender's room.
- The payload now includes 'room': room_id so the recipient can see which room the message came from.
This is the fix for the broadcast-everything problem from the previous step. A Scan touched every row. A Query against the GSI touches only the rows that share the same roomId.
- Click Deploy to save and deploy the function.
✔️ Awesome, I've got everything!
Great. Confirm you see the Changes deployed banner at the top of the function page.
ⓧ I'd like to double check the full code
Your ChatSendMessage/lambda_function.py should have all of these changes applied. Scroll through and confirm:
- The from boto3.dynamodb.conditions import Key import line is present right after import boto3.
- The table.scan() call is gone, replaced by table.get_item(...) and table.query(IndexName='RoomIndex', ...).
- The payload dictionary includes 'room': room_id.
- Everything else (the apigw client, the fan-out loop, the GoneException handler) is unchanged.
Redeploy and test room isolation
The Lambda code is updated, but it is good practice to redeploy the API Gateway stage to ensure the snapshot is current.
- In the AWS Console, navigate to the API Gateway service.
- Select your ChatWebSocketAPI.
- In the main navigation pane, choose Routes.
- Click Deploy API.
- For Stage, choose production.
- Click Deploy.
Time to test. Before you run the next commands, think about this: if you connect two clients to general and one client to engineering, then send a message from a general client, which terminals do you expect to receive it?
- Open three terminal windows.
- In the first terminal, connect to the general room by running:
wscat -c "wss://[[WS_API_ID="your API ID"]].execute-api.[[AWS_REGION="your AWS region"]].amazonaws.com/production?room=general"
- In the second terminal, connect to the same general room by running the same command.
- In the third terminal, connect to the engineering room by running:
wscat -c "wss://[[WS_API_ID="your API ID"]].execute-api.[[AWS_REGION="your AWS region"]].amazonaws.com/production?room=engineering"
- All three terminals should show Connected (press CTRL+C to quit).
- In the first terminal (general room), send a message by typing:
{"action": "sendmessage", "message": "hello general!"}
The second terminal (also general) receives the message. The third terminal (engineering) stays silent. Room isolation is working.
- In the third terminal (engineering room), send a message by typing:
{"action": "sendmessage", "message": "hello engineering!"}
Neither of the general terminals receive it. Messages are fully scoped to rooms.
Messages still going to all clients?
- Make sure you clicked Deploy on both the ChatConnect and ChatSendMessage functions in the Lambda console.
- Confirm the RoomIndex GSI status is Active in the DynamoDB console. A GSI in Creating status cannot be queried.
- Check that you included ?room=general or ?room=engineering at the end of the WebSocket URL when connecting. Without it, both clients default to general and receive each other's messages.
- Verify the IAM inline policy includes dynamodb:Query and dynamodb:GetItem. Without these, the Lambda function gets an AccessDenied error.
Help me debug room isolation in my WebSocket chat
Secret mission
Persist Message History
Your chat rooms work perfectly in real time. But what happens when someone joins late? They miss everything that was said before they connected. Can you add message persistence so newcomers can catch up on the conversation?
Clean Up Your Resources
Clean Up Your Resources
This project uses serverless AWS resources that only incur charges when actively handling connections and messages. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- API Gateway WebSocket API: ChatWebSocketAPI.
- Lambda functions: ChatConnect, ChatDisconnect, ChatSendMessage, and ChatGetHistory.
- DynamoDB tables: ChatConnections and ChatMessages.
- IAM role: ChatLambdaRole.
- wscat npm global package.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your chat system.
- Lambda and DynamoDB fall under the AWS always-free tier. There is no 12-month expiration on their free usage allowance.
- API Gateway WebSocket API charges per message and per connection minute. An idle API with no active connections costs nothing.
- The wscat package stays installed globally on your machine. It uses minimal disk space.
Pause - I'll come back to this later
All of your resources are serverless. They do not run when idle, so there is nothing to pause.
- Lambda functions only execute when triggered by a WebSocket event. No connections means no invocations and no cost.
- DynamoDB tables sit idle until a Lambda function reads from or writes to them.
- If you have any open wscat connections in your terminal, close them by pressing Ctrl+C.
Delete - I don't want to use this again
Remove all project resources and start fresh. Delete the API Gateway WebSocket API first to avoid orphaned route integrations.
- Open the API Gateway console. Select ChatWebSocketAPI from the APIs list. Click Delete and confirm.
- Open the Lambda console. Click Functions in the sidebar. For each of the four functions (ChatConnect, ChatDisconnect, ChatSendMessage, ChatGetHistory), select the function, click Actions, then Delete function.
- Open the DynamoDB console. Click Tables in the sidebar. Select ChatConnections and click Delete table. Repeat for ChatMessages.
- Open the IAM console. Click Roles in the sidebar. Search for ChatLambdaRole, select it, and click Delete. Type the role name to confirm.
- Uninstall wscat from your machine by running:
npm uninstall -g wscat
Nice Work!
Nice Work!
Nice work! You just built a fully serverless real-time chat system on AWS from scratch.
You've learned how to:
- Built a real-time WebSocket chat system using API Gateway, Lambda, and DynamoDB to deliver messages instantly across persistent connections.
- Implemented the connection registry pattern to track active clients in DynamoDB, with GoneException handling to automatically prune stale connections during fan-out.
- Solved the fan-out scoping problem by adding chat room isolation with a DynamoDB Global Secondary Index, ensuring messages only reach clients in the same room.
- Completed an optional Secret Mission to push your skills even further.
Ready to quiz yourself?