tests.ws

AWS WebSocket API Gateway Guide

aws websocket api-gateway lambda serverless

AWS WebSocket API Gateway enables you to build serverless, real-time applications without managing infrastructure. This service manages WebSocket connections at scale while integrating with AWS Lambda for business logic, making it a powerful solution for chat applications, live dashboards, multiplayer games, and collaborative tools.

This guide covers everything you need to build production-ready WebSocket APIs using AWS API Gateway, from connection management to message broadcasting. We’ll explore how api gateway websocket routes work, implement Lambda handlers, manage persistent connections, and deploy your infrastructure using modern tools.

How API Gateway WebSocket Works

AWS WebSocket API Gateway operates differently from traditional WebSocket servers. Instead of maintaining a persistent server process, API Gateway handles connection lifecycle events by invoking Lambda functions based on predefined routes.

When a client connects to your aws websocket api, three core route types determine how messages flow:

$connect Route: Invoked when a client establishes a WebSocket connection. This is where you authenticate users, validate connection parameters, and store connection metadata. If your Lambda function returns a non-200 status code, API Gateway rejects the connection.

$disconnect Route: Triggered when a client disconnects or the connection times out. Use this route to clean up resources and remove connection records from your database. Note that this route is best-effort, meaning it might not trigger in all scenarios like network failures.

$default Route: Handles all messages that don’t match custom routes. This acts as your catch-all message handler. You can also define custom routes based on message content, allowing you to route different message types to different Lambda functions.

The aws websocket api gateway maintains connections for up to 2 hours of idle time. After receiving a message, API Gateway invokes your Lambda function with the message payload and connection metadata. Your function processes the logic and can send responses back through the API Gateway Management API.

A critical difference from traditional WebSocket servers is that aws lambda websocket functions are stateless. Connection state must be persisted externally, typically in DynamoDB, to track active connections and enable message broadcasting.

Creating Your AWS WebSocket API

You can create an aws api gateway websocket through the AWS Console, AWS CLI, or infrastructure as code tools like SAM and CDK. We’ll focus on SAM (Serverless Application Model) for reproducible deployments.

First, create a SAM template that defines your WebSocket API and Lambda functions:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: WebSocket API with Lambda integration

Globals:
  Function:
    Timeout: 30
    Runtime: nodejs18.x
    Environment:
      Variables:
        CONNECTIONS_TABLE: !Ref ConnectionsTable

Resources:
  WebSocketAPI:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: ChatWebSocketAPI
      ProtocolType: WEBSOCKET
      RouteSelectionExpression: "$request.body.action"

  ConnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketAPI
      RouteKey: $connect
      AuthorizationType: NONE
      OperationName: ConnectRoute
      Target: !Join
        - '/'
        - - 'integrations'
          - !Ref ConnectIntegration

  ConnectIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketAPI
      Description: Connect Integration
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ConnectFunction.Arn}/invocations

  DisconnectRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketAPI
      RouteKey: $disconnect
      AuthorizationType: NONE
      OperationName: DisconnectRoute
      Target: !Join
        - '/'
        - - 'integrations'
          - !Ref DisconnectIntegration

  DisconnectIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketAPI
      Description: Disconnect Integration
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DisconnectFunction.Arn}/invocations

  DefaultRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref WebSocketAPI
      RouteKey: $default
      AuthorizationType: NONE
      OperationName: DefaultRoute
      Target: !Join
        - '/'
        - - 'integrations'
          - !Ref DefaultIntegration

  DefaultIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref WebSocketAPI
      Description: Default Integration
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${DefaultFunction.Arn}/invocations

  Deployment:
    Type: AWS::ApiGatewayV2::Deployment
    DependsOn:
      - ConnectRoute
      - DisconnectRoute
      - DefaultRoute
    Properties:
      ApiId: !Ref WebSocketAPI

  Stage:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      StageName: production
      Description: Production Stage
      DeploymentId: !Ref Deployment
      ApiId: !Ref WebSocketAPI

  ConnectionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: WebSocketConnections
      AttributeDefinitions:
        - AttributeName: connectionId
          AttributeType: S
      KeySchema:
        - AttributeName: connectionId
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true

  ConnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handlers/
      Handler: connect.handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ConnectionsTable

  DisconnectFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handlers/
      Handler: disconnect.handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ConnectionsTable

  DefaultFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/handlers/
      Handler: default.handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref ConnectionsTable
        - Statement:
          - Effect: Allow
            Action:
              - execute-api:ManageConnections
            Resource:
              - !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketAPI}/*'

  ConnectFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref ConnectFunction
      Principal: apigateway.amazonaws.com

  DisconnectFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref DisconnectFunction
      Principal: apigateway.amazonaws.com

  DefaultFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref DefaultFunction
      Principal: apigateway.amazonaws.com

Outputs:
  WebSocketURL:
    Description: WebSocket URL
    Value: !Sub wss://${WebSocketAPI}.execute-api.${AWS::Region}.amazonaws.com/${Stage}

This template creates a complete websocket api gateway with DynamoDB for connection storage and proper IAM permissions. The RouteSelectionExpression determines how incoming messages map to routes based on the message body.

Deploy this template using the SAM CLI:

sam build
sam deploy --guided

The guided deployment prompts you for stack name, region, and confirmation settings. After deployment completes, you’ll receive your WebSocket URL in the format wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}.

Implementing Lambda Handlers

Each route requires a Lambda handler that processes connection events and messages. Let’s implement handlers for all three core routes.

Connect Handler

The connect handler validates incoming connections and stores connection metadata:

// src/handlers/connect.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, PutCommand } = require('@aws-sdk/lib-dynamodb');

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

exports.handler = async (event) => {
  const connectionId = event.requestContext.connectionId;
  const timestamp = Date.now();

  // Extract query parameters for user identification
  const queryParams = event.queryStringParameters || {};
  const userId = queryParams.userId;
  const username = queryParams.username || 'Anonymous';

  // Store connection in DynamoDB
  try {
    await docClient.send(new PutCommand({
      TableName: process.env.CONNECTIONS_TABLE,
      Item: {
        connectionId,
        userId,
        username,
        connectedAt: timestamp,
        ttl: Math.floor(timestamp / 1000) + 7200 // 2 hour TTL
      }
    }));

    console.log(`Connection ${connectionId} stored for user ${username}`);

    return {
      statusCode: 200,
      body: 'Connected'
    };
  } catch (error) {
    console.error('Error storing connection:', error);
    return {
      statusCode: 500,
      body: 'Failed to connect'
    };
  }
};

Returning a non-200 status code rejects the connection. You can implement WebSocket authentication here by validating JWT tokens passed as query parameters.

Disconnect Handler

The disconnect handler removes connections from DynamoDB:

// src/handlers/disconnect.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, DeleteCommand } = require('@aws-sdk/lib-dynamodb');

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

exports.handler = async (event) => {
  const connectionId = event.requestContext.connectionId;

  try {
    await docClient.send(new DeleteCommand({
      TableName: process.env.CONNECTIONS_TABLE,
      Key: { connectionId }
    }));

    console.log(`Connection ${connectionId} removed`);

    return {
      statusCode: 200,
      body: 'Disconnected'
    };
  } catch (error) {
    console.error('Error removing connection:', error);
    return {
      statusCode: 500,
      body: 'Failed to disconnect'
    };
  }
};

Default Message Handler

The default handler processes incoming messages and can send responses:

// src/handlers/default.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, GetCommand } = require('@aws-sdk/lib-dynamodb');
const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require('@aws-sdk/client-apigatewaymanagementapi');

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

// Initialize API Gateway Management API client
const getApiGatewayClient = (event) => {
  const endpoint = `https://${event.requestContext.domainName}/${event.requestContext.stage}`;
  return new ApiGatewayManagementApiClient({ endpoint });
};

exports.handler = async (event) => {
  const connectionId = event.requestContext.connectionId;
  const body = JSON.parse(event.body || '{}');

  // Get connection details
  let connection;
  try {
    const result = await docClient.send(new GetCommand({
      TableName: process.env.CONNECTIONS_TABLE,
      Key: { connectionId }
    }));
    connection = result.Item;
  } catch (error) {
    console.error('Error fetching connection:', error);
  }

  // Process message based on action
  const action = body.action || 'message';
  const apiGateway = getApiGatewayClient(event);

  try {
    if (action === 'ping') {
      // Respond with pong
      await apiGateway.send(new PostToConnectionCommand({
        ConnectionId: connectionId,
        Data: JSON.stringify({ type: 'pong', timestamp: Date.now() })
      }));
    } else if (action === 'message') {
      // Echo message back with sender info
      const response = {
        type: 'message',
        username: connection?.username || 'Unknown',
        message: body.message,
        timestamp: Date.now()
      };

      await apiGateway.send(new PostToConnectionCommand({
        ConnectionId: connectionId,
        Data: JSON.stringify(response)
      }));
    }

    return { statusCode: 200, body: 'Message processed' };
  } catch (error) {
    console.error('Error sending message:', error);
    return { statusCode: 500, body: 'Failed to process message' };
  }
};

The API Gateway Management API enables lambda websockets to send data back to clients. You must initialize the client with your API endpoint, which you construct from the event’s requestContext.

Connection Management with DynamoDB

Effective connection management is crucial for aws websocket lambda applications. DynamoDB provides fast, scalable storage for tracking active connections.

The connections table schema should include:

  • connectionId (partition key): Unique identifier for each WebSocket connection
  • userId: Application user identifier for targeting specific users
  • username: Display name or metadata
  • connectedAt: Timestamp for connection tracking
  • ttl: Time-to-live for automatic cleanup

To retrieve all active connections for broadcasting:

const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, ScanCommand } = require('@aws-sdk/lib-dynamodb');

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

async function getAllConnections() {
  const result = await docClient.send(new ScanCommand({
    TableName: process.env.CONNECTIONS_TABLE
  }));

  return result.Items || [];
}

For large-scale applications, consider adding a Global Secondary Index on userId to efficiently query all connections for a specific user. This enables targeted messaging without scanning the entire table.

DynamoDB’s TTL feature automatically removes stale connections. Set the ttl attribute to a Unix timestamp (in seconds) representing when the connection should expire. This handles cases where the $disconnect route doesn’t trigger.

Sending Messages Back to Clients

The API Gateway Management API is how aws api gateway websocket sends data to connected clients. This API is separate from the WebSocket API itself and requires specific IAM permissions.

Here’s a robust function for sending messages with error handling:

const { ApiGatewayManagementApiClient, PostToConnectionCommand, DeleteConnectionCommand } = require('@aws-sdk/client-apigatewaymanagementapi');
const { DynamoDBDocumentClient, DeleteCommand } = require('@aws-sdk/lib-dynamodb');

async function sendMessage(apiGateway, docClient, connectionId, data) {
  try {
    await apiGateway.send(new PostToConnectionCommand({
      ConnectionId: connectionId,
      Data: typeof data === 'string' ? data : JSON.stringify(data)
    }));
    return { success: true };
  } catch (error) {
    if (error.statusCode === 410) {
      // Connection is stale (Gone)
      console.log(`Removing stale connection ${connectionId}`);

      // Clean up from DynamoDB
      await docClient.send(new DeleteCommand({
        TableName: process.env.CONNECTIONS_TABLE,
        Key: { connectionId }
      }));

      return { success: false, stale: true };
    }

    console.error(`Error sending to ${connectionId}:`, error);
    return { success: false, error };
  }
}

A 410 (Gone) error indicates the connection no longer exists. Always catch this error and remove the connection from your database to prevent future send attempts.

The Data parameter accepts either a string or Buffer. For JSON messages, stringify objects before sending. Binary data can be sent as a Buffer for efficient transmission.

Broadcasting Messages to Multiple Connections

Broadcasting is a common requirement for chat applications and live updates. With aws websocket api, broadcasting requires iterating through connections and sending messages individually.

Here’s a complete broadcast handler:

// src/handlers/broadcast.js
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocumentClient, ScanCommand, DeleteCommand } = require('@aws-sdk/lib-dynamodb');
const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require('@aws-sdk/client-apigatewaymanagementapi');

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

const getApiGatewayClient = (event) => {
  const endpoint = `https://${event.requestContext.domainName}/${event.requestContext.stage}`;
  return new ApiGatewayManagementApiClient({ endpoint });
};

exports.handler = async (event) => {
  const connectionId = event.requestContext.connectionId;
  const body = JSON.parse(event.body || '{}');

  // Get sender info
  const senderResult = await docClient.send(new GetCommand({
    TableName: process.env.CONNECTIONS_TABLE,
    Key: { connectionId }
  }));
  const sender = senderResult.Item;

  // Get all connections
  const connectionsResult = await docClient.send(new ScanCommand({
    TableName: process.env.CONNECTIONS_TABLE
  }));
  const connections = connectionsResult.Items || [];

  const apiGateway = getApiGatewayClient(event);
  const message = {
    type: 'broadcast',
    username: sender?.username || 'Anonymous',
    message: body.message,
    timestamp: Date.now()
  };

  const messageData = JSON.stringify(message);

  // Send to all connections
  const sendPromises = connections.map(async (connection) => {
    try {
      await apiGateway.send(new PostToConnectionCommand({
        ConnectionId: connection.connectionId,
        Data: messageData
      }));
    } catch (error) {
      if (error.statusCode === 410) {
        // Remove stale connection
        await docClient.send(new DeleteCommand({
          TableName: process.env.CONNECTIONS_TABLE,
          Key: { connectionId: connection.connectionId }
        }));
      }
    }
  });

  await Promise.all(sendPromises);

  return { statusCode: 200, body: 'Broadcast sent' };
};

For high-volume broadcasting, consider batching operations and implementing pagination if your connections exceed DynamoDB’s 1MB scan limit. You can also use DynamoDB Streams to trigger broadcast Lambda functions when specific data changes occur.

Authentication and Authorization

Securing your websocket api gateway is critical for production applications. AWS provides several authentication mechanisms.

Lambda Authorizers

Lambda authorizers validate tokens before allowing connections:

// src/handlers/authorizer.js
const jwt = require('jsonwebtoken');

exports.handler = async (event) => {
  const token = event.queryStringParameters?.token;

  if (!token) {
    return generatePolicy('user', 'Deny', event.methodArn);
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Return policy allowing connection
    return generatePolicy(decoded.userId, 'Allow', event.methodArn, {
      userId: decoded.userId,
      username: decoded.username
    });
  } catch (error) {
    console.error('Token verification failed:', error);
    return generatePolicy('user', 'Deny', event.methodArn);
  }
};

function generatePolicy(principalId, effect, resource, context = {}) {
  return {
    principalId,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [{
        Action: 'execute-api:Invoke',
        Effect: effect,
        Resource: resource
      }]
    },
    context
  };
}

Add the authorizer to your SAM template:

WebSocketAuthorizer:
  Type: AWS::ApiGatewayV2::Authorizer
  Properties:
    Name: LambdaAuthorizer
    ApiId: !Ref WebSocketAPI
    AuthorizerType: REQUEST
    AuthorizerUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthorizerFunction.Arn}/invocations
    IdentitySource:
      - route.request.querystring.token

ConnectRoute:
  Type: AWS::ApiGatewayV2::Route
  Properties:
    ApiId: !Ref WebSocketAPI
    RouteKey: $connect
    AuthorizationType: CUSTOM
    AuthorizerId: !Ref WebSocketAuthorizer

The authorizer runs before the $connect handler, blocking unauthorized connections. Context values from the authorizer become available in subsequent Lambda invocations through event.requestContext.authorizer.

For more authentication strategies, see our guide on WebSocket authentication.

IAM-Based Authentication

For service-to-service communication, use IAM authentication with SigV4 signing. This requires clients to sign WebSocket requests using AWS credentials:

ConnectRoute:
  Type: AWS::ApiGatewayV2::Route
  Properties:
    ApiId: !Ref WebSocketAPI
    RouteKey: $connect
    AuthorizationType: AWS_IAM

Clients must use libraries that support SigV4 signing for WebSocket connections, such as the AWS IoT Device SDK or custom implementations.

Costs and Limits

Understanding aws websocket pricing and limits helps you design cost-effective applications.

Pricing

AWS WebSocket API Gateway charges for:

  • Messages: $1.00 per million messages (first 1 billion messages per month)
  • Connection minutes: $0.25 per million connection minutes

A connection minute is counted in one-minute increments. A client connected for 30 seconds consumes one connection minute.

Lambda costs are separate:

  • Requests: $0.20 per million requests
  • Duration: $0.0000166667 per GB-second

DynamoDB costs depend on your billing mode:

  • On-demand: $1.25 per million write requests, $0.25 per million read requests
  • Provisioned: Based on configured capacity units

Limits

Key limits for aws lambda websocket applications:

  • Connection duration: Maximum 2 hours idle timeout
  • Message size: 128 KB per message (after base64 encoding for binary data)
  • Connection limit: 10,000 connections per account per region (can be increased)
  • Lambda timeout: Maximum 29 seconds for route handlers
  • Rate limits: 10,000 requests per second per account per region

For applications requiring longer message processing, use asynchronous patterns. Have your Lambda function return immediately while processing continues in the background, then send results back through the Management API.

When you approach limits, request increases through AWS Support or architect around them using multiple APIs or regions.

AWS AppSync as an Alternative

While this guide focuses on api gateway websocket, AWS AppSync provides another option for real-time applications. AppSync websocket support is built on GraphQL subscriptions rather than raw WebSocket messages.

AppSync advantages:

  • GraphQL-based data fetching with subscriptions
  • Automatic connection management
  • Built-in DynamoDB integration with resolvers
  • Fine-grained authorization with multiple auth modes

AppSync disadvantages:

  • Less flexibility for custom protocols
  • GraphQL overhead for simple use cases
  • Higher costs for high-volume messaging

Choose appsync websocket when you want GraphQL benefits and automatic data synchronization. Use API Gateway WebSocket when you need full protocol control, custom message formats, or integration with existing WebSocket clients.

Both services can coexist in your architecture. Use API Gateway for custom real-time features and AppSync for data-driven subscriptions.

Testing Your WebSocket API

Proper testing ensures reliability before production deployment. For comprehensive strategies, see our guide on how to test WebSockets.

Quick testing with wscat:

npm install -g wscat


# Send a message
> {"action": "message", "message": "Hello from wscat"}

# You'll receive the response
< {"type":"message","username":"TestUser","message":"Hello from wscat","timestamp":1708000000000}

For automated testing, use the ws library:

const WebSocket = require('ws');

const ws = new WebSocket('wss://your-api-id.execute-api.us-east-1.amazonaws.com/production?userId=123');

ws.on('open', () => {
  console.log('Connected');
  ws.send(JSON.stringify({ action: 'message', message: 'Test message' }));
});

ws.on('message', (data) => {
  console.log('Received:', data.toString());
});

ws.on('close', () => {
  console.log('Disconnected');
});

Monitor your API using CloudWatch Logs and metrics. Enable execution logging in your API Gateway stage settings to debug message routing and Lambda invocations.

Frequently Asked Questions

How do I handle WebSocket reconnections in AWS API Gateway?

AWS API Gateway does not automatically reconnect clients. Implement reconnection logic in your client application with exponential backoff. When a client reconnects, it receives a new connectionId, so your application must handle re-establishing user sessions. Store session state in DynamoDB using userId as the key rather than connectionId, allowing you to associate new connections with existing sessions.

Can I use AWS WebSocket API Gateway with languages other than Node.js?

Yes, you can implement lambda websockets in any language supported by AWS Lambda, including Python, Java, Go, C#, and Ruby. The API Gateway Management API has SDKs for all major languages. The connection event structure and response format remain the same regardless of runtime. Choose the language that best fits your team’s expertise and performance requirements.

What is the maximum number of concurrent connections for an AWS WebSocket API?

The default limit is 10,000 concurrent connections per account per region. You can request increases through AWS Support for production workloads. For applications exceeding this limit, consider partitioning users across multiple WebSocket APIs or regions. Large-scale applications often use a combination of API Gateway for control plane operations and custom infrastructure for high-volume data plane messaging.

How do I debug failed message deliveries in my WebSocket API?

Enable CloudWatch Logs execution logging in your API Gateway stage settings. This logs all route invocations, Lambda responses, and Management API calls. Use structured logging in your Lambda functions to track message flow. Implement dead-letter queues (DLQ) for Lambda functions to capture failed invocations. Monitor the IntegrationError and ExecutionError CloudWatch metrics to identify patterns in failures. For 410 (Gone) errors, ensure your disconnect handler and stale connection cleanup logic work correctly.