Build a Serverless Chat Pipeline

Build a queue-backed WebSocket chat pipeline on AWS.

Introduction

30 Second Summary

Busy group chats feel instant until traffic spikes or one recipient slows down. People still expect every message to arrive.

In this project, you will build a queue-backed chat pipeline on AWS. The sender gets a fast acknowledgement before background workers deliver the message to every connected client.

What You'll Build

You'll watch a message typed in one Terminal window arrive in both live clients through an Amazon API Gateway WebSocket API.

By the end of this project, you'll have:

  • A live two-client chat where a message typed in either Terminal window appears in both clients.
  • A shared chat memory in Amazon DynamoDB that keeps chat history available across separate AWS Lambda invocations.
  • A queue-backed broadcast path through Amazon SQS that gives the sender a quick queued acknowledgement before both clients receive the message.
  • Secret Mission: Trace a deliberately malformed message through retries into the dead-letter queue.

Are there any prerequisites?

You'll need an AWS account and a Mac.

Step 1 verifies every local tool before you create the chat pipeline.

Before We Start

Before any hands-on work begins, lock in why this chat pipeline separates message receipt from message broadcast. This separation gives queued broadcast work its own path for scaling, retries, and recovery.

Prepare Your Mac and AWS Session

A repeatable deployment depends on known tool versions plus an authenticated AWS identity. Setup problems become much harder to diagnose once they are mixed with application code.

This step gives every later command a predictable foundation. You will finish with verified tools plus a signed-in AWS session.

In this step, get ready to:
  • Verify Node.js, npm, Visual Studio Code, and wscat.
  • Verify AWS CLI v2 plus AWS SAM CLI 1.166.1.
  • Authenticate with AWS before preparing the project workspace.
Verify or install the editor and JavaScript tools

Node.js runs the JavaScript tooling used by the Lambda functions. Visual Studio Code keeps the project files together in one workspace.

  • Press Cmd+Space to open macOS search.
  • Type Terminal into the search bar.
  • Press Enter to open Terminal.
  • Check Node.js plus npm by running these commands:
node --version
npm --version

What Do These Checks Show?

  • The first command reports the installed Node.js runtime.
  • The second command reports the npm package manager bundled with Node.js.

✔️ I see the required Node.js version

Node.js reports v24.20.0. npm also prints a version number.

ⓧ I see an older Node.js version

The older runtime could behave differently from the Lambda runtime used later. Install Node.js 24.20.0 LTS before continuing.

  • Open the official Node.js download page.
  • Download the macOS installer for Node.js 24.20.0 LTS.
  • Open the downloaded installer.
  • Complete the installation using the default options.
  • Return to Terminal after the installation completes.

ⓧ The node command is not found

Installing Node.js 24.20.0 LTS adds both the runtime and npm to your Mac.

  • Open the official Node.js download page.
  • Download the macOS installer for Node.js 24.20.0 LTS.
  • Open the downloaded installer.
  • Complete the installation using the default options.
  • Return to Terminal after the installation completes.
  • Confirm the Node.js installation by running the version checks again:
node --version
npm --version

What Should the Versions Confirm?

Node.js should report v24.20.0. npm should print its installed version on the next line.

The editor also needs a terminal command so it can open the workspace from its exact location. Check whether Visual Studio Code is already available on your Mac.

  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code into the search bar.

✔️ Visual Studio Code is installed

  • Press Enter to open Visual Studio Code.
  • Press Cmd+Shift+P to open the Command Palette.
  • Enter Shell Command: Install 'code' command in PATH.
  • Select the matching Command Palette action.

Visual Studio Code adds the code command to your terminal path.

ⓧ Visual Studio Code is not installed

  • Open the official Visual Studio Code macOS setup guide.
  • Download the macOS .dmg file.
  • Open the downloaded .dmg file.
  • Move Visual Studio Code into your Applications folder.
  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.
  • Press Cmd+Shift+P to open the Command Palette.
  • Enter Shell Command: Install 'code' command in PATH.
  • Select the matching Command Palette action.

The editor is installed. Its terminal command is ready for the workspace later in this step.

The chat test uses wscat as a command-line WebSocket client. Version 6.1.0 keeps the connection behavior consistent throughout the project.

  • Return to Terminal.
  • Check the installed wscat version by running this command:
wscat --version

What Does This Check Show?

The command reports the installed wscat version. The project uses version 6.1.0.

✔️ I see the required wscat version

wscat reports 6.1.0. Your WebSocket client is ready for the two-client chat test.

ⓧ I see another wscat version

  • Install the pinned wscat version by running this command:
npm install -g wscat@6.1.0

What Does This Install Command Do?

npm installs wscat globally so the command is available from any folder. The version suffix pins the installation to 6.1.0.

The global install may take a minute while npm downloads the package files.

ⓧ The wscat command is not found

  • Install the pinned wscat version by running this command:
npm install -g wscat@6.1.0

What Does This Install Command Do?

npm installs wscat globally so the command is available from any folder. The version suffix pins the installation to 6.1.0.

The global install may take a minute while npm downloads the package files.

  • Confirm the pinned wscat installation by running:
wscat --version

What Should wscat Report?

The output should report wscat version 6.1.0.

Still Seeing the Wrong JavaScript Tools?

  • Open a fresh Terminal window after completing an installer.
  • Confirm that npm prints a version before reinstalling wscat.
  • Run each version check again from the fresh Terminal window.

Get help with checking the Node.js or npm command path.

Verify or install the AWS tools

AWS CLI sends authenticated commands to your account. AWS SAM CLI validates the serverless template before building and deploying it.

  • Check both AWS command-line tools by running these commands:
aws --version
sam --version

What Do These Checks Show?

  • The first command identifies the installed AWS CLI release.
  • The second command reports the installed AWS SAM CLI release.

✔️ I see AWS CLI v2

AWS CLI v2 is ready. You will authenticate it in the final substep.

ⓧ I see AWS CLI v1

AWS CLI v2 provides the authentication flow used in this project. Install it before continuing.

  • Install AWS CLI v2 for your macOS user by running this command:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

What Does This Install Command Do?

The command downloads the official AWS CLI installation script over HTTPS. Your shell runs that script to install AWS CLI v2 for the current user.

ⓧ The aws command is not found

Install AWS CLI v2 so your terminal can authenticate with AWS.

  • Install AWS CLI v2 for your macOS user by running this command:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

What Does This Install Command Do?

The command downloads the official AWS CLI installation script over HTTPS. Your shell runs that script to install AWS CLI v2 for the current user.

  • Confirm that AWS CLI v2 is available by running:
aws --version

What Should AWS CLI Report?

The output should identify AWS CLI version 2.

Still Missing the aws Command?

  • Close Terminal after the installation completes.
  • Open a fresh Terminal window to reload your command path.
  • Follow the official AWS CLI installation guide if the installation script reports a problem.

Get help with diagnosing the AWS CLI installation.

AWS SAM CLI is pinned to 1.166.1 so the template commands in this guide use a known release. The native package installer supports macOS 13 or later.

✔️ I see the required SAM version

AWS SAM CLI reports 1.166.1. The build and deployment commands now have the expected baseline.

ⓧ I see an older SAM version

Install AWS SAM CLI 1.166.1 so the project matches this guide.

Apple silicon Macs use aws-sam-cli-macos-arm64.pkg. Intel Macs use aws-sam-cli-macos-x86_64.pkg.

  • Open the official AWS SAM CLI release page.
  • Download the package that matches your Mac processor.
  • Open the downloaded package.
  • Complete the installation using the default options.
  • Follow the official AWS SAM CLI installation guide if your Mac runs a version older than macOS 13.

ⓧ The sam command is not found

Install AWS SAM CLI 1.166.1 with the package that matches your Mac processor.

Apple silicon Macs use aws-sam-cli-macos-arm64.pkg. Intel Macs use aws-sam-cli-macos-x86_64.pkg.

  • Open the official AWS SAM CLI release page.
  • Download the package that matches your Mac processor.
  • Open the downloaded package.
  • Complete the installation using the default options.
  • Follow the official AWS SAM CLI installation guide if your Mac runs a version older than macOS 13.
  • Confirm the AWS SAM CLI installation by running:
sam --version

What Should SAM Report?

The output should report AWS SAM CLI version 1.166.1.

Still Missing the sam Command?

  • Confirm that the downloaded package matches your Mac processor.
  • Close Terminal after the package installer completes.
  • Open a fresh Terminal window before checking the version again.

Get help with checking the SAM installation.

Authenticate and create the workspace

The AWS CLI needs an active identity before it can deploy resources. IAM Identity Center supplies temporary credentials when your AWS environment supports it.

  • Check whether your existing default AWS profile works by running:
aws sts get-caller-identity

What Does This Identity Check Do?

The command asks AWS which identity the CLI is using. A successful response includes UserId, Account, plus Arn fields.

✔️ My default profile returns an identity

Your default AWS profile is authenticated. Keep this Terminal session available for the workspace commands below.

ⓧ My default profile does not work

Configure temporary credentials through IAM Identity Center. The setup opens a browser so you can approve access to the AWS account supplied by your organization.

  • Start the IAM Identity Center configuration by running:
aws configure sso

What Does This Configuration Do?

AWS CLI collects the IAM Identity Center details supplied by your AWS administrator. It creates a named profile that can request temporary credentials.

  • Enter the IAM Identity Center start URL supplied by your AWS administrator.
  • Enter the IAM Identity Center Region supplied by your AWS administrator.
  • Approve the browser sign-in when it opens.
  • Select the AWS account used for this project.
  • Select a role that can deploy the learning stack.
  • Choose a profile name when the CLI requests one.
  • Record the generated profile name here: <profile-name>.
  • Start an IAM Identity Center session for that profile by running:
aws sso login --profile [[PROFILE_NAME="<profile-name>"]]

What Does This Sign-In Command Do?

The command requests temporary credentials for the named profile. Your browser may ask you to approve the sign-in.

  • Verify the authenticated named profile by running:
aws sts get-caller-identity --profile [[PROFILE_NAME="<profile-name>"]]

What Confirms the Session?

A successful response identifies the active user or role. It also identifies the AWS account used for deployment.

Unable to Complete the AWS Sign-In?

  • Confirm that the profile name matches the profile created by AWS CLI.
  • Run the browser approval flow again if the temporary session expired.
  • Ask your AWS administrator which account or deployment role to select if none are available.

Get help with diagnosing the profile authentication flow.

Your tools now have an authenticated AWS identity. The last setup task gives the project a predictable home on your Desktop.

  • Move Terminal to your Desktop by running this command:
cd ~/Desktop

What Does This Command Do?

The command changes Terminal's current location to the Desktop folder for your macOS account. This keeps the new workspace easy to find.

  • Create the serverless-chat workspace plus its src folder by running:
mkdir -p serverless-chat/src && cd serverless-chat

What Does This Workspace Command Do?

  • The first command creates the serverless-chat folder with a nested src folder.
  • The second command moves Terminal into the new workspace.
  • Open the current workspace in Visual Studio Code by running:
code .

What Does This Editor Command Do?

The code command opens Visual Studio Code. The final dot selects the current serverless-chat folder.

Approve the workspace if Visual Studio Code asks whether you trust its authors.

You should see serverless-chat at the top of the file sidebar. Its src folder should appear underneath.

  • Select the new-file icon beside the serverless-chat workspace name.
  • Enter template.yaml as the file name.
  • Press Enter to create the file.
  • Press Cmd+S to save the empty file.

You should see template.yaml beside the src folder in the file sidebar.

  • Select the src folder in the file sidebar.
  • Select the new-file icon beside the src folder.
  • Enter app.js as the file name.
  • Press Enter to create the file.
  • Press Cmd+S to save the empty file.

You should see the empty app.js file inside src.

The final file defines the JavaScript package plus its pinned AWS SDK dependencies. These packages support WebSocket callbacks, DynamoDB access, and SQS messages later in the project.

  • Select the new-file icon beside the src folder.
  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Add the package configuration by pasting this code into src/package.json:
{
  "name": "serverless-chat-functions",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "@aws-sdk/client-apigatewaymanagementapi": "3.1127.0",
    "@aws-sdk/client-dynamodb": "3.1127.0",
    "@aws-sdk/client-sqs": "3.1127.0",
    "@aws-sdk/lib-dynamodb": "3.1127.0"
  }
}

What Does This Configuration Do?

  • The package metadata identifies the Lambda source as a private JavaScript module package.
  • The dependency list pins each AWS SDK package to version 3.1127.0.
  • The API Gateway package supports WebSocket callbacks.
  • The DynamoDB plus SQS packages support shared state and queued messages.
  • Press Cmd+S to save src/package.json.
  • Expand the src folder in the file sidebar.

You should see app.js plus package.json inside src. You should also see template.yaml at the top level.

✔️ Awesome, I've got everything!

Great. Your workspace now has all three files required for the first WebSocket slice.

ⓧ I'd like to double check the full code

Keep template.yaml plus src/app.js empty. Compare src/package.json with this complete file.

{
  "name": "serverless-chat-functions",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "@aws-sdk/client-apigatewaymanagementapi": "3.1127.0",
    "@aws-sdk/client-dynamodb": "3.1127.0",
    "@aws-sdk/client-sqs": "3.1127.0",
    "@aws-sdk/lib-dynamodb": "3.1127.0"
  }
}

What Should Match?

Every package name plus version should match exactly. The two empty files are ready for the application code and infrastructure template in the next step.

Missing a Workspace File?

  • Confirm that Visual Studio Code shows serverless-chat as the workspace name.
  • Confirm that app.js plus package.json are inside the src folder.
  • Confirm that template.yaml sits beside the src folder.

Get help with checking the workspace structure.

Before you run the final checks, which installed tool do you expect to reveal a version mismatch?

  • Verify the complete local toolchain by running these commands in Terminal:
node --version
npm --version
aws --version
sam --version
wscat --version

What Should the Final Check Show?

  • Node.js should report v24.20.0.
  • npm should print an installed version number.
  • AWS CLI should identify version 2.
  • AWS SAM CLI should report 1.166.1.
  • wscat should report 6.1.0.

That is the setup complete. Your Mac can now build the project from a known toolchain while your verified AWS identity handles deployment.

Your authenticated workspace is ready. Next, you will deploy the smallest WebSocket path and watch it echo a message back to its sender.

Deploy the First WebSocket Message

The empty workspace is ready for AWS SAM to turn it into a deployed Amazon API Gateway WebSocket API. This gives your project its first persistent WebSocket endpoint.

A real chat must carry messages across separate connections. This first slice uses AWS Lambda to answer the connection included in one event.

In this step, get ready to:
  • Define the WebSocket API and its message route.
  • Build and deploy the Lambda echo handler.
  • Connect two clients to expose the missing shared state.
Define the WebSocket infrastructure

The SAM template describes the cloud resources that belong together. The route selection expression reads each message's action value to choose the Lambda route.

Why API Gateway WebSockets?

API Gateway WebSocket APIs expose connection IDs through each request. Lambda can use the @connections management API to respond to a specific client.

AWS AppSync Events focuses on managed channels and subscriptions. API Gateway makes the connection ownership visible for this architecture lesson.

  • In the Explorer sidebar of the VS Code workspace from earlier, select template.yaml.
  • Add the template header and WebSocket route by pasting this code into the empty file:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: First WebSocket echo slice

Resources:
  ChatApi:
    Type: AWS::Serverless::WebSocketApi
    Properties:
      Name: serverless-chat-pipeline
      StageName: prod
      RouteSelectionExpression: $request.body.action
      Auth:
        AuthType: NONE
      Routes:
        sendMessage:
          FunctionArn: !GetAtt SendMessageFunction.Arn

What does this resource define?

  • The prod stage gives the deployed WebSocket URL a stable stage path.
  • The $request.body.action expression sends matching messages to the sendMessage route.
  • The NONE authorization type keeps this learning build open to your test clients.
  • Add the Lambda function directly below the ChatApi resource:
  SendMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.sendMessageHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*

How is the function contained?

  • The handler points to sendMessageHandler inside src/app.js.
  • The nodejs24.x runtime matches the JavaScript environment prepared earlier.
  • The inline policy grants only execute-api:ManageConnections access for this API's prod connections.
  • Save template.yaml by pressing Cmd+S.
  • Show the integrated terminal by pressing Ctrl+`.
  • Validate the two resources by running this command:
sam validate

What does this command check?

The command checks whether template.yaml follows the SAM template structure. A successful validation confirms that the API can resolve its referenced Lambda resource.

You should see SAM report that the template is valid.

  • Return to template.yaml in the editor.
  • Add the stack output below the function resource:
Outputs:
  ChatWebSocketUrl:
    Value: !Sub wss://${ChatApi}.execute-api.${AWS::Region}.amazonaws.com/prod

Why output the URL?

The output assembles the deployed API ID and Region into the WebSocket address. You use that address to connect each test client.

  • Save template.yaml.
  • Return to the integrated terminal.
  • Validate the completed template by running:
sam validate

What does the second validation prove?

This check includes the new ChatWebSocketUrl output. A successful result confirms that the complete infrastructure file is structurally valid.

You should see SAM report a valid template again.

✔️ Awesome, I've got everything!

Great. Your WebSocket API definition is valid and ready for its handler.

ⓧ I'd like to double check the full code

Compare your complete template.yaml with this file:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: First WebSocket echo slice

Resources:
  ChatApi:
    Type: AWS::Serverless::WebSocketApi
    Properties:
      Name: serverless-chat-pipeline
      StageName: prod
      RouteSelectionExpression: $request.body.action
      Auth:
        AuthType: NONE
      Routes:
        sendMessage:
          FunctionArn: !GetAtt SendMessageFunction.Arn

  SendMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.sendMessageHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*

Outputs:
  ChatWebSocketUrl:
    Value: !Sub wss://${ChatApi}.execute-api.${AWS::Region}.amazonaws.com/prod

How to compare this file

Check the indentation beneath Properties and Statement first. YAML uses indentation to group each setting with its parent resource.

Template validation failing?

  • Check that ChatApi and SendMessageFunction align beneath Resources.
  • Confirm that Outputs starts at the left edge of the file.
  • Replace any tab characters with spaces because YAML indentation must stay consistent.

Help me fix my SAM template validation error.

Write and deploy the echo handler

The route now needs code that can call back through the open connection. The handler builds the callback endpoint from the incoming request before posting JSON to the sender's connection ID.

  • Select src/app.js in the Explorer sidebar.
  • Add the SDK imports and callback helpers by pasting this code into the empty file:
import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";

const response = (statusCode) => ({ statusCode });

const endpointFromRequest = (requestContext) =>
  `https://${requestContext.domainName}/${requestContext.stage}`;

const postJson = async (endpoint, connectionId, payload) => {
  const client = new ApiGatewayManagementApiClient({ endpoint });
  await client.send(
    new PostToConnectionCommand({
      ConnectionId: connectionId,
      Data: Buffer.from(JSON.stringify(payload)),
    }),
  );
};

How does the callback work?

  • The endpointFromRequest helper rebuilds the HTTPS management endpoint from the WebSocket request context.
  • The postJson helper converts a JavaScript payload into bytes for API Gateway.
  • The PostToConnectionCommand targets one connection through its ID.
  • Add the message handler directly below postJson in src/app.js.
  • Paste this handler at the bottom of the file:
export const sendMessageHandler = async (event) => {
  const endpoint = endpointFromRequest(event.requestContext);
  await postJson(endpoint, event.requestContext.connectionId, {
    type: "echo",
    message: event.body,
  });
  return response(200);
};

What does the handler return?

  • The handler reads the sender's connection ID from event.requestContext.connectionId.
  • The response uses type: "echo" so the client can identify the message.
  • The final 200 status tells API Gateway that Lambda handled the route successfully.
  • Save src/app.js.
  • Return to the integrated terminal.
  • Build the application by running:
sam build

What does the build prepare?

SAM prepares the Lambda source and the dependencies listed in the existing src/package.json file. The resulting build artifacts are ready for deployment.

You should see the build complete successfully.

✔️ Awesome, I've got everything!

Your handler builds successfully and can now be deployed.

ⓧ I'd like to double check the full code

Compare your complete src/app.js with this file:

import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";

const response = (statusCode) => ({ statusCode });

const endpointFromRequest = (requestContext) =>
  `https://${requestContext.domainName}/${requestContext.stage}`;

const postJson = async (endpoint, connectionId, payload) => {
  const client = new ApiGatewayManagementApiClient({ endpoint });
  await client.send(
    new PostToConnectionCommand({
      ConnectionId: connectionId,
      Data: Buffer.from(JSON.stringify(payload)),
    }),
  );
};

export const sendMessageHandler = async (event) => {
  const endpoint = endpointFromRequest(event.requestContext);
  await postJson(endpoint, event.requestContext.connectionId, {
    type: "echo",
    message: event.body,
  });
  return response(200);
};

How to compare this file

Confirm that sendMessageHandler appears after the callback helpers. Check that the handler passes event.requestContext.connectionId to postJson.

Build not completing?

  • Confirm that the integrated terminal started inside the open serverless-chat workspace.
  • Check that template.yaml points to src/ through its CodeUri property.
  • Confirm that the import package name matches the dependency in src/package.json.

Help me troubleshoot my SAM build.

Before you deploy

This action creates an API Gateway WebSocket API and a Lambda function in your AWS account. The test is small, but usage-based charges remain possible until you delete the stack.

The route accepts unauthenticated clients for this focused learning build. A production WebSocket API should protect its connection route with authorization.

  • Enter serverless-chat when the guided deployment asks for the stack name.
  • Approve the creation of IAM roles for the Lambda function.
  • Approve the unauthenticated client route for this learning build.
  • Save the guided deployment settings.
  • Keep the displayed defaults for the remaining prompts.
  • Start the guided deployment by running:
sam deploy --guided

What does guided deployment do?

SAM packages the built application and creates the stack through AWS CloudFormation. It also records the deployment choices you approve for later deployments.

Expect the deployment to take several minutes while AWS creates the API and Lambda resources. Progress updates in the terminal show that the stack is still being prepared.

You should see the stack deployment complete with a ChatWebSocketUrl output.

  • Find ChatWebSocketUrl in the deployment output.
  • Store its value here: <ChatWebSocketUrl>.

Deployment not reaching a complete state?

  • Confirm that the active AWS identity can create CloudFormation stacks and IAM roles.
  • Review the latest failed resource in the deployment progress output.
  • Rerun the deployment after correcting the named resource error.

Help me diagnose my guided SAM deployment failure.

Compare two live clients

Each WebSocket client holds its own persistent connection. Running two clients reveals whether the Lambda has any way to discover a connection outside the current event.

  • Return to the integrated terminal from the deployment.
  • Use the split-terminal control in the terminal toolbar to place a second terminal session beside the first.
  • Connect the first client by replacing <ChatWebSocketUrl> with <ChatWebSocketUrl> in this command:
wscat -c <ChatWebSocketUrl>

What does this connection command do?

The -c option tells wscat to connect to the deployed WebSocket URL. The terminal remains attached so you can send messages through that connection.

  • Connect the second client by replacing <ChatWebSocketUrl> with <ChatWebSocketUrl> in the other terminal session:
wscat -c <ChatWebSocketUrl>

What does the second connection prove?

The second command creates another independent WebSocket connection to the same API. You should see both clients report successful connections.

Before you send the first message, which connected client do you think Lambda can identify from the incoming event?

  • Send a message from the first client by entering this payload:
{"action":"sendMessage","message":"hello"}

How is this message routed?

  • The action value selects the sendMessage route.
  • The message value travels inside the original request body.
  • The Lambda response labels the callback as an echo.
  • Check the first client for the Lambda callback.
  • Check the second client for the same message.

The first client receives a response with type set to echo. The second client receives no chat message.

That shortfall is intentional. Lambda only has the sender's event.requestContext.connectionId because no shared connection list exists yet.

No echo in the first client?

  • Confirm that the payload uses sendMessage as its action value.
  • Check that the client is still connected to the prod WebSocket URL.
  • Confirm that the latest deployment completed after the Lambda code was saved.

Help me troubleshoot the missing WebSocket echo.

You have your first deployed message path working. Next, you will give separate Lambda invocations shared memory so both clients can receive the same message.

Add Shared Chat Memory

Your Amazon API Gateway WebSocket API now carries messages to an AWS Lambda function. However, each invocation only knows about the client that triggered it.

A chat needs shared memory for active connections and message history. Amazon DynamoDB gives every function access to the same state, so one sender can reach every connected client.

In this step, get ready to:
  • Add tables for active connections and lobby message history.
  • Add connection lifecycle handlers with separate permissions.
  • Broadcast a stored message to two WebSocket clients.
Add the shared-state resources

The AWS SAM template defines the shared state that survives between Lambda invocations. One table tracks presence. The other table stores lobby messages.

  • In template.yaml, locate the Resources: line.
  • Add both table definitions directly below Resources: by pasting this code:
  ConnectionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: connectionId
          AttributeType: S
      KeySchema:
        - AttributeName: connectionId
          KeyType: HASH

  MessagesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: roomId
          AttributeType: S
        - AttributeName: messageId
          AttributeType: S
      KeySchema:
        - AttributeName: roomId
          KeyType: HASH
        - AttributeName: messageId
          KeyType: RANGE

How do the tables divide the work?

  • The connections table uses connectionId as its partition key, so each live client has one record.
  • The messages table groups records by roomId. Its messageId sort key keeps each message unique.
  • The PAY_PER_REQUEST billing mode lets DynamoDB handle bursty learning traffic without fixed capacity settings.
  • Save template.yaml.
  • Validate the new table definitions by running this command in the terminal from earlier:
sam validate

You should see confirmation that the SAM template is valid. This proves both table schemas use supported resource properties.

Does the template fail validation?

  • Check that ConnectionsTable and MessagesTable align beneath Resources:.
  • Check that each property uses spaces for indentation.
  • Compare every AttributeName value with the matching entry under KeySchema.

Help me fix my DynamoDB table definitions.

WebSocket lifecycle routes tell the backend when a client joins or leaves. Their handlers keep the connections table aligned with the clients that API Gateway currently knows about.

  • In template.yaml, locate the end of the existing ChatApi resource.
  • Add the connection handler resource below ChatApi by pasting this code:
  ConnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.connectHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt ConnectionsTable.Arn

What does the connection function need?

  • The CONNECTIONS_TABLE environment variable gives the handler its deployed table name.
  • The role permits only dynamodb:PutItem on the connections table.
  • The connectHandler handler records each new connection.
  • Add the disconnection handler resource below ConnectFunction by pasting this code:
  DisconnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.disconnectHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:DeleteItem
              Resource: !GetAtt ConnectionsTable.Arn

Why use a separate function?

The disconnection function only removes records. Its role permits dynamodb:DeleteItem on the connections table.

AWS SAM generates a separate AWS Identity and Access Management role because the template omits an explicit Role property. This keeps each function inside its own least-privilege boundary.

  • Save template.yaml.
  • Validate both lifecycle resources by running:
sam validate

You should see confirmation that the template is valid. Both function resources now point to the connections table.

Does a lifecycle resource fail validation?

  • Confirm both functions remain inside the top-level Resources section.
  • Confirm ConnectFunction uses app.connectHandler.
  • Confirm DisconnectFunction uses app.disconnectHandler.

Help me fix my WebSocket lifecycle resources.

The functions exist in the template. The WebSocket API now needs routes that invoke them at the start and end of each connection.

  • In template.yaml, locate the complete ChatApi resource.
  • Replace that resource with this version:
  ChatApi:
    Type: AWS::Serverless::WebSocketApi
    Properties:
      Name: serverless-chat-pipeline
      StageName: prod
      RouteSelectionExpression: $request.body.action
      Auth:
        AuthType: NONE
      Routes:
        $connect:
          FunctionArn: !GetAtt ConnectFunction.Arn
        $disconnect:
          FunctionArn: !GetAtt DisconnectFunction.Arn
        sendMessage:
          FunctionArn: !GetAtt SendMessageFunction.Arn

How do the lifecycle routes work?

  • The $connect route invokes ConnectFunction when a client establishes a WebSocket connection.
  • The $disconnect route invokes DisconnectFunction when API Gateway closes a connection.
  • The sendMessage route continues to invoke SendMessageFunction for chat payloads.
  • Save template.yaml.
  • Validate the route references by running:
sam validate

You should see confirmation that the template is valid. Each route now references a function declared in the same stack.

The send function also needs access to both tables. It writes message history to one table. It reads live connection IDs from the other table.

  • In SendMessageFunction, locate the Timeout: 10 line.
  • Add these environment variables directly below that line:
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
          MESSAGES_TABLE: !Ref MessagesTable

Why pass table names as environment variables?

Deployment generates the physical table names. These environment variables give the same names to sendMessageHandler at runtime.

  • In the same SendMessageFunction resource, locate the existing Policies block.
  • Replace that block with these direct-broadcast permissions:
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt MessagesTable.Arn
            - Effect: Allow
              Action:
                - dynamodb:Scan
                - dynamodb:DeleteItem
              Resource: !GetAtt ConnectionsTable.Arn
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*

What can the send function access?

  • The function can write a chat record to MessagesTable.
  • The function can scan connection IDs in ConnectionsTable.
  • The function can remove a stale connection after GoneException.
  • The function can post a response through this API's prod stage.
  • Save template.yaml.
  • Validate the send function configuration by running:
sam validate

You should see confirmation that the template is valid. The send function now has the table names and permissions required for direct broadcasting.

Does the send function fail validation?

  • Confirm Environment aligns with Policies inside Properties.
  • Confirm the messages policy references MessagesTable.Arn.
  • Confirm the scan policy references ConnectionsTable.Arn.

Help me fix the SendMessageFunction configuration.

Stack outputs make deployed resource names available after each deployment. You need the messages table name for the final history check.

  • In template.yaml, locate the existing Outputs section.
  • Replace that section with these three outputs:
Outputs:
  ChatWebSocketUrl:
    Description: WebSocket URL for the prod stage
    Value: !Sub wss://${ChatApi}.execute-api.${AWS::Region}.amazonaws.com/prod
  ConnectionsTableName:
    Description: DynamoDB table containing live connection IDs
    Value: !Ref ConnectionsTable
  MessagesTableName:
    Description: DynamoDB table containing lobby message history
    Value: !Ref MessagesTable

What do the outputs expose?

The outputs expose the deployed WebSocket URL and both generated table names. They contain resource identifiers, not credentials.

  • Save template.yaml.
  • Validate the completed shared-memory template by running:
sam validate

You should see confirmation that the completed template is valid. The stack now describes two tables and three route functions.

✔️ Awesome, I've got everything!

Your shared-memory resources are defined. Double check that template.yaml is saved before continuing.

ⓧ I'd like to double check the full code

Compare your complete template.yaml file with this version:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: First WebSocket echo slice

Resources:
  ConnectionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: connectionId
          AttributeType: S
      KeySchema:
        - AttributeName: connectionId
          KeyType: HASH

  MessagesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: roomId
          AttributeType: S
        - AttributeName: messageId
          AttributeType: S
      KeySchema:
        - AttributeName: roomId
          KeyType: HASH
        - AttributeName: messageId
          KeyType: RANGE

  ChatApi:
    Type: AWS::Serverless::WebSocketApi
    Properties:
      Name: serverless-chat-pipeline
      StageName: prod
      RouteSelectionExpression: $request.body.action
      Auth:
        AuthType: NONE
      Routes:
        $connect:
          FunctionArn: !GetAtt ConnectFunction.Arn
        $disconnect:
          FunctionArn: !GetAtt DisconnectFunction.Arn
        sendMessage:
          FunctionArn: !GetAtt SendMessageFunction.Arn

  ConnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.connectHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt ConnectionsTable.Arn

  DisconnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.disconnectHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:DeleteItem
              Resource: !GetAtt ConnectionsTable.Arn

  SendMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.sendMessageHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
          MESSAGES_TABLE: !Ref MessagesTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt MessagesTable.Arn
            - Effect: Allow
              Action:
                - dynamodb:Scan
                - dynamodb:DeleteItem
              Resource: !GetAtt ConnectionsTable.Arn
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*

Outputs:
  ChatWebSocketUrl:
    Description: WebSocket URL for the prod stage
    Value: !Sub wss://${ChatApi}.execute-api.${AWS::Region}.amazonaws.com/prod
  ConnectionsTableName:
    Description: DynamoDB table containing live connection IDs
    Value: !Ref ConnectionsTable
  MessagesTableName:
    Description: DynamoDB table containing lobby message history
    Value: !Ref MessagesTable
Store and broadcast each message

The current handler sends an echo directly to its sender. The new handler saves a structured chat event before loading every active connection ID.

  • In src/app.js, select everything from the first import through the closing brace of postJson.
  • Replace that section with these imports and shared clients:
import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DeleteCommand,
  DynamoDBDocumentClient,
  PutCommand,
  ScanCommand,
} from "@aws-sdk/lib-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

const response = (statusCode) => ({ statusCode });

const endpointFromRequest = (requestContext) =>
  `https://${requestContext.domainName}/${requestContext.stage}`;

const postJson = async (endpoint, connectionId, payload) => {
  const client = new ApiGatewayManagementApiClient({ endpoint });
  await client.send(new PostToConnectionCommand({ ConnectionId: connectionId, Data: Buffer.from(JSON.stringify(payload)) }));
};

What does this section prepare?

  • The DynamoDB imports provide commands for writing, scanning, and deleting records.
  • The ddb document client sends JavaScript objects to DynamoDB.
  • The existing API Gateway client still sends JSON payloads to WebSocket connections.
  • Save src/app.js.
  • Build the application code by running:
sam build

You should see a successful build. This confirms the new SDK imports resolve from the existing src/package.json dependencies.

  • In src/app.js, locate the line immediately above sendMessageHandler.
  • Add the payload parser and connection scanner at that location:
const parseBody = (body) => {
  try { return JSON.parse(body ?? "{}"); } catch { return {}; }
};

const listConnectionIds = async () => {
  const page = await ddb.send(new ScanCommand({ TableName: process.env.CONNECTIONS_TABLE, ProjectionExpression: "connectionId" }));
  return (page.Items ?? []).map((item) => item.connectionId);
};

How does the handler read shared state?

  • The parseBody helper converts the incoming JSON string into an object.
  • Invalid JSON becomes an empty object, which the message validation rejects.
  • The listConnectionIds helper reads only the connectionId attribute from the connections table.
  • Add both lifecycle handlers directly below listConnectionIds by pasting this code:
export const connectHandler = async (event) => {
  await ddb.send(new PutCommand({ TableName: process.env.CONNECTIONS_TABLE, Item: { connectionId: event.requestContext.connectionId, connectedAt: new Date().toISOString() } }));
  return response(200);
};

export const disconnectHandler = async (event) => {
  await ddb.send(new DeleteCommand({ TableName: process.env.CONNECTIONS_TABLE, Key: { connectionId: event.requestContext.connectionId } }));
  return response(200);
};

What do the lifecycle handlers store?

  • The connectHandler stores the connection ID with an ISO timestamp.
  • The disconnectHandler deletes the record for the connection that closed.
  • Both handlers return an HTTP status code to API Gateway.
  • Save src/app.js.
  • Build the lifecycle handlers by running:
sam build

You should see a successful build. The handler names in template.yaml now match exported functions in src/app.js.

The final handler replaces sender-only echoing with persistence and direct fan-out. It also removes connection records that API Gateway reports as stale.

  • In src/app.js, locate the existing sendMessageHandler function.
  • Replace the complete function with this direct-broadcast version:
export const sendMessageHandler = async (event) => {
  const body = parseBody(event.body);
  const message = typeof body.message === "string" ? body.message.trim() : "";
  const endpoint = endpointFromRequest(event.requestContext);
  const senderConnectionId = event.requestContext.connectionId;
  if (!message) return response(400);
  const chatEvent = { type: "message", roomId: "lobby", messageId: `${Date.now()}#${event.requestContext.requestId}`, senderConnectionId, message: message.slice(0, 500), sentAt: new Date().toISOString() };
  await ddb.send(new PutCommand({ TableName: process.env.MESSAGES_TABLE, Item: chatEvent }));
  const connectionIds = await listConnectionIds();
  await Promise.all(connectionIds.map(async (connectionId) => {
    try { await postJson(endpoint, connectionId, chatEvent); }
    catch (error) { if (error?.name === "GoneException") await ddb.send(new DeleteCommand({ TableName: process.env.CONNECTIONS_TABLE, Key: { connectionId } })); else throw error; }
  }));
  return response(200);
};

How does direct broadcasting work?

  • The handler trims body.message and rejects an empty value.
  • The chatEvent object records the lobby message with a unique ID and timestamp.
  • The handler saves the event before scanning active connection IDs.
  • A GoneException removes a stale connection without stopping delivery to healthy clients.
  • Save src/app.js.
  • Build the complete shared-memory application by running:
sam build

You should see a successful build. The packaged functions are ready to store presence and broadcast messages.

Does the application fail to build?

  • Confirm every imported command appears once at the top of src/app.js.
  • Confirm connectHandler and disconnectHandler appear before sendMessageHandler.
  • Confirm the previous echo-only version of sendMessageHandler was fully replaced.

Help me fix my shared-memory Lambda code.

✔️ Awesome, I've got everything!

Your Lambda code builds successfully. Double check that src/app.js is saved before deployment.

ⓧ I'd like to double check the full code

Compare your complete src/app.js file with this version:

import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DeleteCommand,
  DynamoDBDocumentClient,
  PutCommand,
  ScanCommand,
} from "@aws-sdk/lib-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

const response = (statusCode) => ({ statusCode });

const endpointFromRequest = (requestContext) =>
  `https://${requestContext.domainName}/${requestContext.stage}`;

const postJson = async (endpoint, connectionId, payload) => {
  const client = new ApiGatewayManagementApiClient({ endpoint });
  await client.send(new PostToConnectionCommand({ ConnectionId: connectionId, Data: Buffer.from(JSON.stringify(payload)) }));
};

const parseBody = (body) => {
  try { return JSON.parse(body ?? "{}"); } catch { return {}; }
};

const listConnectionIds = async () => {
  const page = await ddb.send(new ScanCommand({ TableName: process.env.CONNECTIONS_TABLE, ProjectionExpression: "connectionId" }));
  return (page.Items ?? []).map((item) => item.connectionId);
};

export const connectHandler = async (event) => {
  await ddb.send(new PutCommand({ TableName: process.env.CONNECTIONS_TABLE, Item: { connectionId: event.requestContext.connectionId, connectedAt: new Date().toISOString() } }));
  return response(200);
};

export const disconnectHandler = async (event) => {
  await ddb.send(new DeleteCommand({ TableName: process.env.CONNECTIONS_TABLE, Key: { connectionId: event.requestContext.connectionId } }));
  return response(200);
};

export const sendMessageHandler = async (event) => {
  const body = parseBody(event.body);
  const message = typeof body.message === "string" ? body.message.trim() : "";
  const endpoint = endpointFromRequest(event.requestContext);
  const senderConnectionId = event.requestContext.connectionId;
  if (!message) return response(400);
  const chatEvent = { type: "message", roomId: "lobby", messageId: `${Date.now()}#${event.requestContext.requestId}`, senderConnectionId, message: message.slice(0, 500), sentAt: new Date().toISOString() };
  await ddb.send(new PutCommand({ TableName: process.env.MESSAGES_TABLE, Item: chatEvent }));
  const connectionIds = await listConnectionIds();
  await Promise.all(connectionIds.map(async (connectionId) => {
    try { await postJson(endpoint, connectionId, chatEvent); }
    catch (error) { if (error?.name === "GoneException") await ddb.send(new DeleteCommand({ TableName: process.env.CONNECTIONS_TABLE, Key: { connectionId } })); else throw error; }
  }));
  return response(200);
};
Redeploy and prove shared memory

The template and handlers now describe one shared view of the chat. Redeployment creates the tables and connects each WebSocket route to its new function.

  • Redeploy the updated serverless-chat stack by running:
sam build && sam deploy

The deployment can take a few minutes while AWS updates the stack. You should see confirmation that the stack update completed successfully.

Does the deployment fail?

  • Read the first failed resource name in the deployment output.
  • Run sam validate again if the failure points to the template.
  • Run sam build again if the failure points to a Lambda function.

Help me diagnose my shared-memory deployment.

  • Display the deployed stack outputs by running:
sam list stack-outputs --stack-name serverless-chat --output table

You should see ChatWebSocketUrl, ConnectionsTableName, and MessagesTableName in the output table.

  • Copy the value beside ChatWebSocketUrl: your ChatWebSocketUrl.
  • Copy the value beside MessagesTableName: your MessagesTableName.
  • Reconnect a client in each of the two Terminal windows from earlier by running this command in both windows:
wscat -c [[CHAT_WEBSOCKET_URL="your ChatWebSocketUrl"]]

Both clients should remain connected with a prompt ready for input. Their connection IDs are now stored in the connections table.

Before you send the message, consider whether one client or both clients will receive it.

  • Send a lobby message from the first client by pasting this payload:
{"action":"sendMessage","message":"hello"}

Both clients should display the same structured message event. The event includes roomId, messageId, senderConnectionId, message, and sentAt.

Does only one client receive the message?

  • Reconnect both clients after the deployment so the new $connect route records them.
  • Confirm both terminals use the same ChatWebSocketUrl.
  • Confirm the sent payload contains the sendMessage action and a non-empty message value.

Help me debug direct WebSocket broadcasting.

  • Inspect the stored lobby history by running:
aws dynamodb scan --table-name [[MESSAGES_TABLE_NAME="your MessagesTableName"]]

You should see an Items field containing the hello message. That record proves the message survived beyond the Lambda invocation that received it.

That is the shared-memory gap closed: separate Lambda invocations can now find the same clients and message history. Next, you will move the broadcast work away from the sender's request path.

Queue Broadcast Work

Your two WebSocket clients now share messages through Amazon DynamoDB. However, the inbound AWS Lambda function makes the sender wait while it contacts every connected client.

This step places Amazon SQS between message receipt and broadcast work. The inbound function can persist each message before quickly handing off the delivery work.

In this step, get ready to:
  • Add a source queue with a dead-letter queue for repeated failures.
  • Convert the inbound Lambda function into an SQS producer.
  • Deploy the queue boundary and observe where the message path stops.
Add buffering and failure storage

A message queue holds work after the inbound request finishes. A dead-letter queue isolates messages that keep failing after repeated delivery attempts.

  • Switch back to template.yaml in Visual Studio Code.
  • Find the end of the MessagesTable resource.
  • Add the two queue resources below it by copying this code:
  DeadLetterQueue:
    Type: AWS::SQS::Queue

  MessageQueue:
    Type: AWS::SQS::Queue
    Properties:
      VisibilityTimeout: 60
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt DeadLetterQueue.Arn
        maxReceiveCount: 3

How does the redrive path work?

  • The MessageQueue resource holds broadcast work until it can be processed.
  • The VisibilityTimeout value hides a received message for 60 seconds while processing takes place.
  • The RedrivePolicy moves a repeatedly failing message to DeadLetterQueue after three receives.
  • Find the existing Outputs section near the bottom of template.yaml.
  • Add the queue URL outputs below MessagesTableName by copying this code:
  MessageQueueUrl:
    Description: SQS source queue URL
    Value: !Ref MessageQueue
  DeadLetterQueueUrl:
    Description: SQS dead-letter queue URL
    Value: !Ref DeadLetterQueue

Why output both queue URLs?

The outputs expose the source queue and failure queue after deployment. You can inspect each queue without searching for its generated name.

  • Save template.yaml.
  • Check the updated template by running this command in the terminal from earlier:
sam validate

What does this check prove?

The command checks whether the SAM template follows the expected structure. A successful result confirms that the queue resources and outputs are valid template definitions.

You should see confirmation that template.yaml is valid.

Template validation failing?

  • Check that DeadLetterQueue and MessageQueue align with the other entries under Resources.
  • Check that both new URL outputs align with the existing entries under Outputs.

Ask for help with the exact validation output: Why is my SAM template failing validation after I added MessageQueue and DeadLetterQueue?

Turn the inbound Lambda into a producer

The inbound function keeps responsibility for validating each message and saving its history. It now sends a JSON copy to SQS instead of scanning every active connection.

  • Switch back to src/app.js.
  • Add the SQS client import below the DynamoDB imports by copying this line:
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";

What does this import provide?

  • The SQSClient class creates the connection used for SQS requests.
  • The SendMessageCommand class describes the message body and destination queue.
  • Find the existing ddb declaration.
  • Replace that declaration with these two client declarations:
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const sqs = new SQSClient({});

Why keep separate clients?

The ddb client handles shared history in DynamoDB. The sqs client hands broadcast work to the queue.

  • Save src/app.js.
  • Confirm the new import and client compile by running this command:
sam build

What does this build check?

The build prepares the Lambda code for deployment. A successful build confirms that the SQS package import resolves correctly.

You should see a successful build result for the application.

  • Find the single-line empty-message check inside sendMessageHandler.
  • Replace that check with this sender-facing validation response:
  if (!message) {
    await postJson(endpoint, senderConnectionId, {
      type: "error",
      message: "Send a non-empty message.",
    });
    return response(400);
  }

Why acknowledge invalid messages?

The handler sends a useful response over the existing WebSocket connection before returning an error status. Empty messages never reach DynamoDB or SQS.

  • Find the one-line chatEvent object inside sendMessageHandler.
  • Replace that object with this expanded queue message:
  const chatEvent = {
    type: "message",
    roomId: "lobby",
    messageId: `${Date.now()}#${event.requestContext.requestId}`,
    senderConnectionId,
    message: message.slice(0, 500),
    sentAt: new Date().toISOString(),
    domainName: event.requestContext.domainName,
    stage: event.requestContext.stage,
  };

Why include the API location?

The queued event carries the WebSocket domain and stage that accepted the message. A later Lambda invocation can rebuild the callback endpoint without relying on the original request.

  • Save src/app.js.
  • Confirm the validation and event changes compile by running:
sam build

What does this build confirm?

The build checks the updated handler syntax before the direct broadcast code is replaced. This keeps the feedback loop close to your latest edits.

You should see another successful build result.

  • Find the history write inside sendMessageHandler.
  • Select from that PutCommand call through the existing direct-broadcast Promise.all call.
  • Replace the selected code with this persist, enqueue, and acknowledge sequence:
  await ddb.send(
    new PutCommand({
      TableName: process.env.MESSAGES_TABLE,
      Item: chatEvent,
    }),
  );

  await sqs.send(
    new SendMessageCommand({
      QueueUrl: process.env.MESSAGE_QUEUE_URL,
      MessageBody: JSON.stringify(chatEvent),
    }),
  );

  await postJson(endpoint, senderConnectionId, {
    type: "queued",
    messageId: chatEvent.messageId,
  });

What does the new message path do?

  • The first command preserves the message in MessagesTable before any delivery work begins.
  • The second command serializes chatEvent into the source queue named by MESSAGE_QUEUE_URL.
  • The final callback tells the sender that the message has reached the queue boundary.
  • Delete the entire listConnectionIds function from src/app.js.
  • Save src/app.js.
  • Check the producer version by running:
sam build

What does this build prove?

The build confirms that sendMessageHandler no longer depends on the deleted connection scan. The inbound path now compiles as a queue producer.

  • Switch back to template.yaml.
  • Find the complete SendMessageFunction resource.
  • Replace that resource with this queue-producer configuration:
  SendMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.sendMessageHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          MESSAGES_TABLE: !Ref MessagesTable
          MESSAGE_QUEUE_URL: !Ref MessageQueue
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt MessagesTable.Arn
            - Effect: Allow
              Action: sqs:SendMessage
              Resource: !GetAtt MessageQueue.Arn
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*

How does the role stay focused?

  • The environment gives the function only the message table name and source queue URL it needs.
  • The role permits history writes with dynamodb:PutItem.
  • The role permits queue writes with sqs:SendMessage.
  • The role retains execute-api:ManageConnections so the function can acknowledge the sender.

✔️ Awesome, I've got everything!

Your producer code and queue resources are ready. Double check that both edited files are saved.

ⓧ I'd like to double check the full code

Compare your queue-producer version of src/app.js with this reference:

import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DeleteCommand,
  DynamoDBDocumentClient,
  PutCommand,
  ScanCommand,
} from "@aws-sdk/lib-dynamodb";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const sqs = new SQSClient({});

const response = (statusCode) => ({ statusCode });

const endpointFromRequest = (requestContext) =>
  `https://${requestContext.domainName}/${requestContext.stage}`;

const postJson = async (endpoint, connectionId, payload) => {
  const client = new ApiGatewayManagementApiClient({ endpoint });
  await client.send(
    new PostToConnectionCommand({
      ConnectionId: connectionId,
      Data: Buffer.from(JSON.stringify(payload)),
    }),
  );
};

const parseBody = (body) => {
  try {
    return JSON.parse(body ?? "{}");
  } catch {
    return {};
  }
};

export const connectHandler = async (event) => {
  await ddb.send(
    new PutCommand({
      TableName: process.env.CONNECTIONS_TABLE,
      Item: {
        connectionId: event.requestContext.connectionId,
        connectedAt: new Date().toISOString(),
      },
    }),
  );

  return response(200);
};

export const disconnectHandler = async (event) => {
  await ddb.send(
    new DeleteCommand({
      TableName: process.env.CONNECTIONS_TABLE,
      Key: { connectionId: event.requestContext.connectionId },
    }),
  );

  return response(200);
};

export const sendMessageHandler = async (event) => {
  const body = parseBody(event.body);
  const message = typeof body.message === "string" ? body.message.trim() : "";
  const endpoint = endpointFromRequest(event.requestContext);
  const senderConnectionId = event.requestContext.connectionId;

  if (!message) {
    await postJson(endpoint, senderConnectionId, {
      type: "error",
      message: "Send a non-empty message.",
    });
    return response(400);
  }

  const chatEvent = {
    type: "message",
    roomId: "lobby",
    messageId: `${Date.now()}#${event.requestContext.requestId}`,
    senderConnectionId,
    message: message.slice(0, 500),
    sentAt: new Date().toISOString(),
    domainName: event.requestContext.domainName,
    stage: event.requestContext.stage,
  };

  await ddb.send(
    new PutCommand({
      TableName: process.env.MESSAGES_TABLE,
      Item: chatEvent,
    }),
  );

  await sqs.send(
    new SendMessageCommand({
      QueueUrl: process.env.MESSAGE_QUEUE_URL,
      MessageBody: JSON.stringify(chatEvent),
    }),
  );

  await postJson(endpoint, senderConnectionId, {
    type: "queued",
    messageId: chatEvent.messageId,
  });

  return response(200);
};

What should this file contain?

This reference keeps the connection handlers and message validation from earlier. The send handler now persists each message before enqueueing it and acknowledging the sender.

Compare your queue-boundary version of template.yaml with this reference:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Queue-backed serverless WebSocket chat pipeline

Resources:
  ConnectionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: connectionId
          AttributeType: S
      KeySchema:
        - AttributeName: connectionId
          KeyType: HASH

  MessagesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: roomId
          AttributeType: S
        - AttributeName: messageId
          AttributeType: S
      KeySchema:
        - AttributeName: roomId
          KeyType: HASH
        - AttributeName: messageId
          KeyType: RANGE

  DeadLetterQueue:
    Type: AWS::SQS::Queue

  MessageQueue:
    Type: AWS::SQS::Queue
    Properties:
      VisibilityTimeout: 60
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt DeadLetterQueue.Arn
        maxReceiveCount: 3

  ChatApi:
    Type: AWS::Serverless::WebSocketApi
    Properties:
      Name: serverless-chat-pipeline
      StageName: prod
      RouteSelectionExpression: $request.body.action
      Auth:
        AuthType: NONE
      Routes:
        $connect:
          FunctionArn: !GetAtt ConnectFunction.Arn
        $disconnect:
          FunctionArn: !GetAtt DisconnectFunction.Arn
        sendMessage:
          FunctionArn: !GetAtt SendMessageFunction.Arn

  ConnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.connectHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt ConnectionsTable.Arn

  DisconnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.disconnectHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:DeleteItem
              Resource: !GetAtt ConnectionsTable.Arn

  SendMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.sendMessageHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          MESSAGES_TABLE: !Ref MessagesTable
          MESSAGE_QUEUE_URL: !Ref MessageQueue
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt MessagesTable.Arn
            - Effect: Allow
              Action: sqs:SendMessage
              Resource: !GetAtt MessageQueue.Arn
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*

Outputs:
  ChatWebSocketUrl:
    Description: WebSocket URL for the prod stage
    Value: !Sub wss://${ChatApi}.execute-api.${AWS::Region}.amazonaws.com/prod
  ConnectionsTableName:
    Description: DynamoDB table containing live connection IDs
    Value: !Ref ConnectionsTable
  MessagesTableName:
    Description: DynamoDB table containing lobby message history
    Value: !Ref MessagesTable
  MessageQueueUrl:
    Description: SQS source queue URL
    Value: !Ref MessageQueue
  DeadLetterQueueUrl:
    Description: SQS dead-letter queue URL
    Value: !Ref DeadLetterQueue

What should this template contain?

This reference keeps the two DynamoDB tables and three WebSocket routes. It adds the source queue, dead-letter queue, producer permission, and both queue outputs.

Observe the queue boundary

The application now has a separate handoff point for broadcast work. You are about to test how far one message travels through the updated architecture.

  • Save template.yaml.
  • Build and redeploy the updated stack by running:
sam build && sam deploy

What does this deployment change?

The deployment creates both queues and updates the inbound Lambda configuration. It also publishes the two queue URLs as stack outputs.

The deployment can take a few minutes while AWS updates the stack. A quiet terminal during this period does not mean the deployment has stalled.

  • Record the MessageQueueUrl output here: your source queue URL.
  • Record the DeadLetterQueueUrl output here: your dead-letter queue URL.
  • Return to the two Terminal windows used for your WebSocket clients.
  • Reconnect each client to the deployed URL by running this command in each window:
wscat -c <ChatWebSocketUrl>

Why reconnect both clients?

Redeployment can replace or restart parts of the WebSocket backend. Fresh connections ensure both connection IDs are stored in the current table state.

Before you send the message, which client do you think receives a response from the new queue-backed path?

  • Send this payload from the first connected client:
{"action":"sendMessage","message":"hello"}

What just happened?

The sender receives a response whose type value is queued. The second client receives no broadcast.

This shortfall is intentional. The producer has persisted and queued the message, but no function is draining the queue yet.

  • Inspect the source queue by replacing <MessageQueueUrl> with the source queue URL you recorded in this command:
aws sqs get-queue-attributes --queue-url <MessageQueueUrl> --attribute-names ApproximateNumberOfMessages

What does the queue count show?

The response reports the approximate number of visible messages waiting in the source queue. A value greater than zero proves that the inbound function completed the handoff.

The approximate count can take at least one minute to settle. Give the metric time to update before treating a zero as a failure.

You should see an ApproximateNumberOfMessages value greater than zero after the metric settles.

Queue still empty?

  • Confirm the sender received a response with the queued type before checking the queue.
  • Confirm the queue URL belongs to MessageQueueUrl instead of DeadLetterQueueUrl.
  • Wait at least one minute before checking the approximate message count again.

Ask for help with your observed output: Why is my queue-backed WebSocket message not appearing in MessageQueue?

You have proved that the sender can finish quickly while SQS holds the broadcast work. Next, you will add the event-driven Lambda that drains the queue and delivers each message to both clients.

Complete Asynchronous Broadcasting

Your Amazon SQS queue now protects the message path from slow fan-out. Senders receive a quick acknowledgement while queued work waits for a consumer.

An AWS Lambda consumer closes the path. It drains the queue in batches. It also isolates failed records so healthy messages keep moving.

In this step, get ready to:
  • Connect a broadcast function to the message queue.
  • Broadcast queued messages to every active connection.
  • Deploy the completed pipeline and prove that the queue drains.
Add the queue consumer

An event source mapping connects a queue to a function. AWS polls for available records before invoking the consumer.

Why use an event source mapping?

The mapping keeps broadcast work away from the sender's request path. The consumer can process as many as five queued records in one batch.

Partial batch reporting identifies individual failures. Successful records can leave the queue while failed records remain available for retry.

  • Return to template.yaml in Visual Studio Code.
  • Find the Outputs: line near the bottom of the file.
  • Insert the broadcast function resource directly above that line by adding:
  BroadcastFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.broadcastHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable

What does this function resource do?

  • The Handler value points Lambda to broadcastHandler in src/app.js.
  • The 10 second function timeout pairs with the queue's 60 second visibility timeout.
  • The CONNECTIONS_TABLE variable supplies the active connection table name.
  • Save template.yaml.
  • Validate the new function resource by running:
sam validate

What does this validation check?

AWS SAM checks the template structure before deployment. A successful result confirms that the new resource is valid YAML.

You should see confirmation that template.yaml is a valid SAM template.

Function resource failing validation?

  • Check that BroadcastFunction aligns with the other entries under Resources.
  • Confirm that each nested YAML level uses two more spaces than its parent.

Share the validation output without credentials: Why is my BroadcastFunction resource failing SAM validation?

The worker needs a narrow permission boundary. It reads queue records through Amazon SQS. It scans active connections in Amazon DynamoDB before posting callbacks through the WebSocket API.

  • Find the Environment block inside BroadcastFunction.
  • Add the worker's Policies block directly below it:
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:Scan
                - dynamodb:DeleteItem
              Resource: !GetAtt ConnectionsTable.Arn
            - Effect: Allow
              Action:
                - sqs:ReceiveMessage
                - sqs:DeleteMessage
                - sqs:GetQueueAttributes
              Resource: !GetAtt MessageQueue.Arn
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*

How does least privilege apply here?

  • The DynamoDB permissions let the worker scan connection IDs. They also let it delete stale connection records.
  • The SQS permissions let Lambda receive records. They also support successful deletion and queue inspection.
  • The API Gateway permission covers connection callbacks for this API's prod stage.
  • Save template.yaml.
  • Validate the permission structure by running:
sam validate

What does this validation prove?

A successful result confirms that AWS SAM can parse the inline policy. Deployment later gives the worker a separate execution role.

You should see another successful template validation.

Policy block rejected?

  • Check that Policies aligns with Environment under Properties.
  • Confirm that each permission sits beneath its matching Action key.

Share the validation output without credentials: Help me fix the BroadcastFunction policy structure.

The final configuration connects MessageQueue to the worker. Partial batch responses allow retries to target only failed records.

  • Find the end of the Policies block inside BroadcastFunction.
  • Add the Events block beneath the policies:
      Events:
        QueueMessages:
          Type: SQS
          Properties:
            Queue: !GetAtt MessageQueue.Arn
            BatchSize: 5
            FunctionResponseTypes:
              - ReportBatchItemFailures

How does the queue trigger work?

  • The QueueMessages event connects the source queue to BroadcastFunction.
  • The batch size allows one invocation to receive as many as five records.
  • The partial batch response prevents successful records from being retried with a failed record.
  • Save template.yaml.
  • Validate the completed consumer configuration by running:
sam validate

What should pass now?

The template now contains the worker configuration. It also contains the worker's permission boundary and SQS event source.

You should see confirmation that the completed SAM template is valid.

Event source failing validation?

  • Confirm that Events aligns with Policies.
  • Check that FunctionResponseTypes contains one list item named ReportBatchItemFailures.

Share the validation output without credentials: Why is my SQS event source failing SAM validation?

✔️ Awesome, I've got everything!

Your template now defines a least-privilege queue consumer with partial batch failure reporting.

ⓧ I'd like to double check the full code

Compare your complete template.yaml with this reference:

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Queue-backed serverless WebSocket chat pipeline

Resources:
  ConnectionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: connectionId
          AttributeType: S
      KeySchema:
        - AttributeName: connectionId
          KeyType: HASH

  MessagesTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: roomId
          AttributeType: S
        - AttributeName: messageId
          AttributeType: S
      KeySchema:
        - AttributeName: roomId
          KeyType: HASH
        - AttributeName: messageId
          KeyType: RANGE

  DeadLetterQueue:
    Type: AWS::SQS::Queue

  MessageQueue:
    Type: AWS::SQS::Queue
    Properties:
      VisibilityTimeout: 60
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt DeadLetterQueue.Arn
        maxReceiveCount: 3

  ChatApi:
    Type: AWS::Serverless::WebSocketApi
    Properties:
      Name: serverless-chat-pipeline
      StageName: prod
      RouteSelectionExpression: $request.body.action
      Auth:
        AuthType: NONE
      Routes:
        $connect:
          FunctionArn: !GetAtt ConnectFunction.Arn
        $disconnect:
          FunctionArn: !GetAtt DisconnectFunction.Arn
        sendMessage:
          FunctionArn: !GetAtt SendMessageFunction.Arn

  ConnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.connectHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt ConnectionsTable.Arn

  DisconnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.disconnectHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:DeleteItem
              Resource: !GetAtt ConnectionsTable.Arn

  SendMessageFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.sendMessageHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          MESSAGES_TABLE: !Ref MessagesTable
          MESSAGE_QUEUE_URL: !Ref MessageQueue
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: dynamodb:PutItem
              Resource: !GetAtt MessagesTable.Arn
            - Effect: Allow
              Action: sqs:SendMessage
              Resource: !GetAtt MessageQueue.Arn
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*

  BroadcastFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/
      Handler: app.broadcastHandler
      Runtime: nodejs24.x
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          CONNECTIONS_TABLE: !Ref ConnectionsTable
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:Scan
                - dynamodb:DeleteItem
              Resource: !GetAtt ConnectionsTable.Arn
            - Effect: Allow
              Action:
                - sqs:ReceiveMessage
                - sqs:DeleteMessage
                - sqs:GetQueueAttributes
              Resource: !GetAtt MessageQueue.Arn
            - Effect: Allow
              Action: execute-api:ManageConnections
              Resource: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${ChatApi}/prod/POST/@connections/*
      Events:
        QueueMessages:
          Type: SQS
          Properties:
            Queue: !GetAtt MessageQueue.Arn
            BatchSize: 5
            FunctionResponseTypes:
              - ReportBatchItemFailures

Outputs:
  ChatWebSocketUrl:
    Description: WebSocket URL for the prod stage
    Value: !Sub wss://${ChatApi}.execute-api.${AWS::Region}.amazonaws.com/prod
  ConnectionsTableName:
    Description: DynamoDB table containing live connection IDs
    Value: !Ref ConnectionsTable
  MessagesTableName:
    Description: DynamoDB table containing lobby message history
    Value: !Ref MessagesTable
  MessageQueueUrl:
    Description: SQS source queue URL
    Value: !Ref MessageQueue
  DeadLetterQueueUrl:
    Description: SQS dead-letter queue URL
    Value: !Ref DeadLetterQueue

This reference preserves the existing resources and outputs. The new BroadcastFunction appears directly before Outputs.

Broadcast and clean up stale connections

The worker needs every active connection ID before it can fan out a message. A paginated scan follows each DynamoDB page so longer connection lists are handled completely.

  • Switch back to src/app.js from earlier.
  • Find the import from @aws-sdk/lib-dynamodb.
  • Add ScanCommand, directly after PutCommand,.
  • Save src/app.js.
  • Confirm that the updated import resolves by running:
sam build

What does this build check?

AWS SAM prepares the function source with the updated import. A successful build confirms that the existing DynamoDB package exports ScanCommand.

You should see a successful build result for the functions in the stack.

Build failing after the import?

  • Confirm that ScanCommand sits inside the existing DynamoDB import braces.
  • Check that the new line ends with one comma.

Share the build output without credentials: Why is ScanCommand breaking my app.js import?

  • Find the end of parseBody in src/app.js.
  • Add the paginated connection helper directly below it:
const listConnectionIds = async () => {
  const connectionIds = [];
  let exclusiveStartKey;

  do {
    const page = await ddb.send(
      new ScanCommand({
        TableName: process.env.CONNECTIONS_TABLE,
        ProjectionExpression: "connectionId",
        ...(exclusiveStartKey
          ? { ExclusiveStartKey: exclusiveStartKey }
          : {}),
      }),
    );

    connectionIds.push(...(page.Items ?? []).map((item) => item.connectionId));
    exclusiveStartKey = page.LastEvaluatedKey;
  } while (exclusiveStartKey);

  return connectionIds;
};

How does pagination protect the broadcast?

  • The scan requests only the connectionId attribute needed for broadcasting.
  • Each page appends its connection IDs to connectionIds.
  • The loop continues while DynamoDB returns a LastEvaluatedKey.
  • Save src/app.js.
  • Build the connection helper by running:
sam build

What should the build confirm?

The build checks that the helper parses with the existing application. It also confirms that the new DynamoDB command resolves.

You should see another successful build result.

Connection helper not building?

  • Check the commas after ProjectionExpression and the conditional spread block.
  • Confirm that listConnectionIds ends before connectHandler begins.

Share the syntax error without credentials: Help me fix my paginated listConnectionIds helper.

Each SQS record contains one serialized chat event. The worker parses that event before posting it to every active connection.

You will add broadcastRecord in two consecutive fragments. The second fragment completes the function before you save or build the file.

  • Find the end of sendMessageHandler in src/app.js.
  • Start broadcastRecord directly below it with this fragment:
const broadcastRecord = async (record) => {
  const chatEvent = JSON.parse(record.body);
  const endpoint = `https://${chatEvent.domainName}/${chatEvent.stage}`;
  const connectionIds = await listConnectionIds();

What does this fragment prepare?

The worker converts the queue body back into a chat event. It rebuilds the callback endpoint from the stored domain and stage.

The connection lookup supplies every recipient. The next fragment completes this same function.

  • Continue directly beneath the first fragment with the rest of broadcastRecord.

  await Promise.all(
    connectionIds.map(async (connectionId) => {
      try {
        await postJson(endpoint, connectionId, {
          type: "message",
          roomId: chatEvent.roomId,
          messageId: chatEvent.messageId,
          senderConnectionId: chatEvent.senderConnectionId,
          message: chatEvent.message,
          sentAt: chatEvent.sentAt,
        });
      } catch (error) {
        if (error?.name === "GoneException") {
          await ddb.send(
            new DeleteCommand({
              TableName: process.env.CONNECTIONS_TABLE,
              Key: { connectionId },
            }),
          );
          return;
        }
        throw error;
      }
    }),
  );
};

How does one record reach every client?

  • The Promise.all call starts one callback for each active connection ID.
  • The payload preserves the stored room and message details.
  • A GoneException identifies a stale connection. The worker deletes that connection record from DynamoDB.
  • Any other error reaches the batch handler so the queue record can be retried.
  • Save src/app.js.
  • Build the record worker by running:
sam build

What does this build prove?

A successful build confirms that the two fragments form one complete function. It also confirms that the existing callback and DynamoDB helpers remain available.

You should see a successful build result.

Record worker not building?

  • Confirm that the second fragment begins directly after the connectionIds line.
  • Check that the final }; closes broadcastRecord.

Share the build output without credentials: Help me fix the structure of my broadcastRecord function.

The batch handler decides which records succeeded. Its response lists only failed message IDs so successful records can leave the queue.

  • Add broadcastHandler directly below broadcastRecord by pasting:
export const broadcastHandler = async (event) => {
  const batchItemFailures = [];

  for (const record of event.Records) {
    try {
      await broadcastRecord(record);
    } catch (error) {
      console.error("Broadcast failed", {
        messageId: record.messageId,
        error,
      });
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }

  return { batchItemFailures };
};

How are failures isolated?

  • The loop gives each SQS record its own error boundary.
  • A failed record adds its messageId to batchItemFailures.
  • The returned response asks Lambda to retry only those failed records.
  • Save src/app.js.
  • Build the completed worker by running:
sam build

What should succeed now?

AWS SAM now builds the producer and consumer from the same source file. A successful result confirms that the completed application parses.

You should see a successful build result for the functions in the stack.

Batch handler failing to build?

  • Confirm that broadcastHandler appears after broadcastRecord.
  • Check that the returned object uses the existing batchItemFailures variable.

Share the build output without credentials: Why is my broadcastHandler failing to build?

✔️ Awesome, I've got everything!

Your source now contains paginated connection lookup, broadcast fan-out, stale connection cleanup, and partial batch reporting.

ⓧ I'd like to double check the full code

Compare your complete src/app.js with this reference:

import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DeleteCommand,
  DynamoDBDocumentClient,
  PutCommand,
  ScanCommand,
} from "@aws-sdk/lib-dynamodb";
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const sqs = new SQSClient({});

const response = (statusCode) => ({ statusCode });

const endpointFromRequest = (requestContext) =>
  `https://${requestContext.domainName}/${requestContext.stage}`;

const postJson = async (endpoint, connectionId, payload) => {
  const client = new ApiGatewayManagementApiClient({ endpoint });
  await client.send(
    new PostToConnectionCommand({
      ConnectionId: connectionId,
      Data: Buffer.from(JSON.stringify(payload)),
    }),
  );
};

const parseBody = (body) => {
  try {
    return JSON.parse(body ?? "{}");
  } catch {
    return {};
  }
};

const listConnectionIds = async () => {
  const connectionIds = [];
  let exclusiveStartKey;

  do {
    const page = await ddb.send(
      new ScanCommand({
        TableName: process.env.CONNECTIONS_TABLE,
        ProjectionExpression: "connectionId",
        ...(exclusiveStartKey
          ? { ExclusiveStartKey: exclusiveStartKey }
          : {}),
      }),
    );

    connectionIds.push(...(page.Items ?? []).map((item) => item.connectionId));
    exclusiveStartKey = page.LastEvaluatedKey;
  } while (exclusiveStartKey);

  return connectionIds;
};

export const connectHandler = async (event) => {
  await ddb.send(
    new PutCommand({
      TableName: process.env.CONNECTIONS_TABLE,
      Item: {
        connectionId: event.requestContext.connectionId,
        connectedAt: new Date().toISOString(),
      },
    }),
  );

  return response(200);
};

export const disconnectHandler = async (event) => {
  await ddb.send(
    new DeleteCommand({
      TableName: process.env.CONNECTIONS_TABLE,
      Key: { connectionId: event.requestContext.connectionId },
    }),
  );

  return response(200);
};

export const sendMessageHandler = async (event) => {
  const body = parseBody(event.body);
  const message = typeof body.message === "string" ? body.message.trim() : "";
  const endpoint = endpointFromRequest(event.requestContext);
  const senderConnectionId = event.requestContext.connectionId;

  if (!message) {
    await postJson(endpoint, senderConnectionId, {
      type: "error",
      message: "Send a non-empty message.",
    });
    return response(400);
  }

  const chatEvent = {
    type: "message",
    roomId: "lobby",
    messageId: `${Date.now()}#${event.requestContext.requestId}`,
    senderConnectionId,
    message: message.slice(0, 500),
    sentAt: new Date().toISOString(),
    domainName: event.requestContext.domainName,
    stage: event.requestContext.stage,
  };

  await ddb.send(
    new PutCommand({
      TableName: process.env.MESSAGES_TABLE,
      Item: chatEvent,
    }),
  );

  await sqs.send(
    new SendMessageCommand({
      QueueUrl: process.env.MESSAGE_QUEUE_URL,
      MessageBody: JSON.stringify(chatEvent),
    }),
  );

  await postJson(endpoint, senderConnectionId, {
    type: "queued",
    messageId: chatEvent.messageId,
  });

  return response(200);
};

const broadcastRecord = async (record) => {
  const chatEvent = JSON.parse(record.body);
  const endpoint = `https://${chatEvent.domainName}/${chatEvent.stage}`;
  const connectionIds = await listConnectionIds();

  await Promise.all(
    connectionIds.map(async (connectionId) => {
      try {
        await postJson(endpoint, connectionId, {
          type: "message",
          roomId: chatEvent.roomId,
          messageId: chatEvent.messageId,
          senderConnectionId: chatEvent.senderConnectionId,
          message: chatEvent.message,
          sentAt: chatEvent.sentAt,
        });
      } catch (error) {
        if (error?.name === "GoneException") {
          await ddb.send(
            new DeleteCommand({
              TableName: process.env.CONNECTIONS_TABLE,
              Key: { connectionId },
            }),
          );
          return;
        }
        throw error;
      }
    }),
  );
};

export const broadcastHandler = async (event) => {
  const batchItemFailures = [];

  for (const record of event.Records) {
    try {
      await broadcastRecord(record);
    } catch (error) {
      console.error("Broadcast failed", {
        messageId: record.messageId,
        error,
      });
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }

  return { batchItemFailures };
};

This file preserves the existing connection handlers and queue producer. The consumer code begins with listConnectionIds and ends with broadcastHandler.

Prove the final message path

The source queue now has a configured consumer. Deployment activates the event source mapping so waiting records can move through the broadcast worker.

This stack update can take a few minutes. AWS creates the function role before connecting the queue trigger.

  • Build and deploy the completed pipeline by running:
sam build && sam deploy

What does this deployment change?

The build prepares the final functions. The deployment creates BroadcastFunction with its SQS event source mapping.

AWS also applies the worker's separate permission boundary. The existing API and data resources remain in the stack.

You should see the stack update finish successfully. The source queue now has an active consumer.

Deployment update failing?

  • Read the failed resource name in the deployment output.
  • Run sam validate if the failure points to the template.
  • Run sam build if the failure points to the function source.

Share the failed resource without credentials: Help me diagnose my BroadcastFunction deployment failure.

Before you test the completed path, predict the visible result. Do you expect one client or both clients to receive the next valid message?

  • Return to the first Terminal window from earlier.
  • Reconnect the first client by running:
wscat -c <ChatWebSocketUrl>

What does this command do?

The client opens a persistent connection to the deployed WebSocket URL. That connection is recorded in ConnectionsTable.

  • Return to the second Terminal window from earlier.
  • Reconnect the second client by running:
wscat -c <ChatWebSocketUrl>

What does the second connection prove?

The second client creates another active connection record. The worker can now find two recipients during its paginated scan.

Client connection failing?

  • Confirm that you replaced <ChatWebSocketUrl> with the current stack output.
  • Check that both Terminal windows remain connected before sending a message.

Share the connection output without credentials: Why can my wscat client not connect to ChatWebSocketUrl?

  • Send a valid chat message from the first client by typing:
{"action":"sendMessage","message":"hello"}

What path does this message take?

The inbound function validates the message. It persists the chat event before placing the work on the source queue.

The sender receives the queued acknowledgement first. The worker then consumes the record before posting the broadcast to every active connection.

You should see a queued acknowledgement in the first client. Both clients should then display the broadcast message.

  • Send the same valid payload from the second client.
  • Send the same valid payload once more from the first client.

Both clients should display all three broadcasts. Each sender should also receive a quick queued acknowledgement for its own messages.

Only seeing queued acknowledgements?

  • Confirm that deployment completed after you added the QueueMessages event.
  • Check that both clients reconnected after deployment.
  • Confirm that broadcastHandler is exported from src/app.js.

Describe the symptoms without sharing credentials: Why does queued appear while no broadcast reaches my clients?

The approximate queue count can take at least one minute to settle. During that pause, predict whether the visible message count should remain above zero or return to zero.

  • Inspect the source queue after the metric settles by running:
aws sqs get-queue-attributes --queue-url <MessageQueueUrl> --attribute-names ApproximateNumberOfMessages

What does this queue check show?

The command reads the approximate number of visible records in the source queue. A zero count shows that the consumer drained the valid messages.

You should see ApproximateNumberOfMessages return 0 after processing and metric propagation.

Queue count still above zero?

  • Wait at least one minute before checking the approximate count again.
  • Confirm that <MessageQueueUrl> was replaced with the source queue output.
  • Check whether both clients received the broadcasts before investigating the trigger.

Share the returned attributes without credentials: Why is my source queue count still above zero?

  • Explain the completed flow aloud using these stages: receive, persist, enqueue, consume, broadcast.

That is the asynchronous path working end to end. Your senders stay responsive while the queue-backed worker handles fan-out and isolates failed records.

Secret mission

Trace a Poison Message into the Dead-Letter Queue

A permanently malformed message can waste worker capacity through repeated retries. Trace one failed record into the dead-letter queue while valid chat traffic keeps moving.

Clean Up Your Resources

Leaving the stack deployed can create small pay-as-you-go charges.

Amazon API Gateway WebSocket APIs can charge for messages plus connection minutes.

Amazon DynamoDB on-demand capacity can also incur charges.

Amazon CloudWatch Logs can add log charges. Choose Pause or Delete if you have finished testing.

Nice Work!

Nice Work!

You pulled it off! Your deployed queue-backed chat pipeline now carries WebSocket messages between two live clients on AWS.

You've learned how to:

  • Build a persistent WebSocket message path for two live terminal clients. Route connection events through Amazon API Gateway WebSocket APIs. Handle each route with AWS Lambda.
  • Use Amazon DynamoDB as shared state for active connection IDs. Persist lobby message history between Lambda invocations. Broadcast each message to every connected client.
  • Buffer inbound messages with Amazon SQS. Give each Lambda function least-privilege permissions through AWS Identity and Access Management. Retry only failed queue records with partial batch responses.
  • Complete the Secret Mission by tracing a malformed poison message through retries into the dead-letter queue. Valid chat records continued processing during the failure.

Ready to quiz yourself?