AWS API Gateway WebSocket APIs #

You want real-time features without running WebSocket servers, and your stack is already serverless on AWS. API Gateway’s WebSocket APIs hold the connections for you and invoke Lambda functions for connection events and messages; your code never owns a socket. That model removes most of the operational work covered elsewhere on this site — capacity, OS limits, draining — and replaces it with a different set of constraints: connections are capped at two hours, idle connections close after ten minutes, every outbound message is an HTTPS call to a management API, and broadcasting to a room means looping over stored connection IDs. Understanding those constraints up front decides whether API Gateway fits your feature.

Root cause #

A conventional WebSocket server keeps each connection as an object in memory, and sending a message is a function call on that object. API Gateway inverts this. The gateway holds the TCP connection and assigns it a connection ID. When something happens — a client connects, sends a message, or disconnects — the gateway invokes an integration (usually Lambda) with the connection ID in the request context. To send a message to a client, your code calls the gateway’s @connections management API with that ID.

Everything that follows comes from that inversion. Your functions are stateless, so the set of connections in a room must live in a database (typically DynamoDB). Broadcasting is N API calls for N recipients. Connection lifetime is governed by the gateway, not by you: connections are closed after two hours regardless of activity and after ten minutes without messages. And costs scale with connection minutes plus messages in both directions, rather than with servers.

The API Gateway WebSocket model The client connects and API Gateway invokes a connect Lambda that stores the connection id in DynamoDB; a chat message is routed to a Lambda that queries the room's connections and calls PostToConnection for each, and the gateway delivers the messages. The API Gateway WebSocket model Client API Gateway Lambda DynamoDB wss connect (auth on $connect) $connect, connectionId store connectionId + room send {action: chat} route 'chat' query room members PostToConnection × N messages delivered The gateway owns the socket; your code owns the connection registry
Sockets become IDs, and sending becomes an API call.

Resolution #

Define routes for $connect, $disconnect and your message actions (selected by a field in the JSON body, $request.body.action by default). Authenticate on $connect — it is the only request with headers and query parameters — and store the connection with its identity and subscriptions. For broadcasts, query the room’s connections and post to each, removing connections that return 410 Gone.

import { ApiGatewayManagementApiClient, PostToConnectionCommand, GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand, DeleteCommand, QueryCommand } from '@aws-sdk/lib-dynamodb';
import type { APIGatewayProxyWebsocketHandlerV2 } from 'aws-lambda';

const TABLE = process.env.CONNECTIONS_TABLE!;
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const CONNECTION_TTL_S = 2 * 60 * 60 + 300; // gateway caps connections at 2 h; TTL cleans leftovers

declare function verifyTicket(ticket: string | undefined): Promise<{ userId: string } | null>;

export const onConnect: APIGatewayProxyWebsocketHandlerV2 = async (event: any) => {
const user = await verifyTicket(event.queryStringParameters?.ticket); // browsers can't set headers
if (!user) return { statusCode: 401 }; // rejects the upgrade
const room = event.queryStringParameters?.room ?? 'lobby';
await ddb.send(new PutCommand({ TableName: TABLE, Item: {
pk: `room#${room}`, sk: `conn#${event.requestContext.connectionId}`,
userId: user.userId, expiresAt: Math.floor(Date.now() / 1000) + CONNECTION_TTL_S,
} }));
return { statusCode: 200 };
};

export const onDisconnect: APIGatewayProxyWebsocketHandlerV2 = async (event: any) => {
// $disconnect is best-effort: stale rows are also cleaned by 410s and TTL.
const room = await roomOf(event.requestContext.connectionId);
if (room) await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { pk: `room#${room}`, sk: `conn#${event.requestContext.connectionId}` } }));
return { statusCode: 200 };
};

export const onChat: APIGatewayProxyWebsocketHandlerV2 = async (event: any) => {
const { domainName, stage, connectionId } = event.requestContext;
const api = new ApiGatewayManagementApiClient({ endpoint: `https://${domainName}/${stage}` });
const { room, text } = JSON.parse(event.body ?? '{}');
const members = await ddb.send(new QueryCommand({
TableName: TABLE, KeyConditionExpression: 'pk = :pk', ExpressionAttributeValues: { ':pk': `room#${room}` },
}));
const payload = Buffer.from(JSON.stringify({ type: 'chat', room, text, from: connectionId }));
// Fan out in parallel; each recipient is one management API call.
await Promise.all((members.Items ?? []).map(async (m) => {
const target = String(m.sk).slice('conn#'.length);
try {
await api.send(new PostToConnectionCommand({ ConnectionId: target, Data: payload }));
} catch (e) {
if (e instanceof GoneException) { // client already gone: prune the registry
await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { pk: m.pk, sk: m.sk } }));
} else throw e;
}
}));
return { statusCode: 200 };
};
declare function roomOf(connectionId: string): Promise<string | null>;

The registry design — keys, indexes, TTL and cleanup — is the main piece of engineering here and is covered in storing WebSocket connection state in DynamoDB. Authentication must happen on $connect because later messages carry no headers; short-lived tickets in the query string work well, as described in authenticating WebSockets with short-lived tickets.

Clients must expect the gateway’s lifecycle: send an application-level ping at least every few minutes to beat the ten-minute idle timeout, and reconnect (with resume) when the two-hour cap closes the connection. Because the cap applies to everyone who connected at roughly the same time, add jitter or proactively reconnect at a random point before two hours to avoid synchronized reconnects.

Work per broadcast message Broadcasting through API Gateway costs one management API call per recipient, rising to five thousand calls for a five-thousand-member room, while a self-hosted node serializes once and loops in memory. Work per broadcast message one management API call per recipient vs one in-memory loop per node API calls per broadcast (gateway) sends per broadcast per node (self-hosted) 0 2.0k 4.0k 6.0k Room of 10 Room of 500 5.0k Room of 5,000
Fan-out cost scales with room size in calls, latency and money — plan large rooms carefully.

Edge cases #

Large rooms. Posting to thousands of connections from one Lambda invocation is slow and can hit account-level rate limits on the management API. Batch recipients across parallel invocations (via SQS or Step Functions), and consider whether very large broadcast audiences belong on a different system.

Message size. WebSocket frames through API Gateway are limited (32 KB per frame, with messages up to 128 KB), and payloads larger than that need chunking or an HTTP fetch triggered by a small notification.

Cold starts. A cold Lambda adds latency to the first message after idle periods. For latency-sensitive routes, use provisioned concurrency or keep functions small and fast to initialize.

Verification #

Use wscat against the stage URL to exercise each route, and CloudWatch to confirm the lifecycle:

npx wscat -c "wss://abc123.execute-api.eu-west-1.amazonaws.com/prod?ticket=$TICKET&room=r1"
> {"action":"chat","room":"r1","text":"hello"}
# Connection count and message volume (CloudWatch metrics for the API):
aws cloudwatch get-metric-statistics --namespace AWS/ApiGateway --metric-name ConnectCount \
--dimensions Name=ApiId,Value=abc123 --start-time $(date -u -d '-1 hour' +%FT%TZ) \
--end-time $(date -u +%FT%TZ) --period 300 --statistics Sum

Leave a connection idle for eleven minutes and confirm the client reconnects cleanly; leave an active one open past two hours and confirm the same. Count GoneExceptions per broadcast — a steady rate is normal; a rising rate means the registry is accumulating dead connections faster than it is pruned.

Connection lifetime under API Gateway A connection opens at zero minutes; application pings every few minutes prevent the ten-minute idle close; the client proactively reconnects at a jittered point before the hard two-hour cap. Connection lifetime under API Gateway connect ($connect) (0 min) app ping (beats idle limit) (5 min) idle close if no traffic (10 min) client proactive reconnect (112 min) hard 2 h cap (120 min) Reconnecting at a random point before the cap avoids a synchronized wave at 2 h
Two gateway limits every client must plan around.

Operational checklist #

FAQ #

How long can an API Gateway WebSocket connection stay open? #

Up to two hours, and it is closed after ten minutes without messages. Clients must send periodic messages and reconnect when the cap is reached.

How do I broadcast to all clients with API Gateway WebSockets? #

Store connection IDs in a database, query the recipients, and call PostToConnection for each one, removing IDs that return 410 Gone. There is no built-in broadcast or room feature.

Can I authenticate with headers on API Gateway WebSockets? #

Only on $connect, and browsers cannot set custom headers there. Use a short-lived ticket in the query string, a cookie, or a Lambda authorizer reading them.

Is API Gateway WebSocket cheaper than running servers? #

For spiky or low-volume traffic, often yes: you pay per connection minute and per message with no idle servers. For large, steady fleets with heavy fan-out, per-message pricing and per-recipient API calls usually make self-hosted servers cheaper. See managed vs self-hosted WebSocket services.

Back to Serverless & Managed WebSockets.