API Gateway WebSocket PostToConnection With AWS SDK v3

Network switch with many blue and yellow Ethernet cables plugged in

Photo by Brett Sayles on Pexels

API Gateway WebSocket PostToConnection in SDK v3 is PostToConnectionCommand from @aws-sdk/client-apigatewaymanagementapi. Create the client with endpoint set to https://{api-id}.execute-api.{region}.amazonaws.com/{stage}, send a ConnectionId and the message as Data, and delete the stored ID when the call throws GoneException (HTTP 410), which means the client has disconnected.

A WebSocket API in API Gateway keeps the connection open, but your backend never holds the socket. To push something to a browser or device, a Lambda function or any other backend calls the @connections management API with the client’s connection ID. This guide is for Node.js and TypeScript developers wiring that up with API Gateway WebSocket PostToConnection in SDK v3: storing connection IDs, sending to one client, broadcasting to many without tripping limits, and cleaning up the IDs that go stale.

Every sample was type-checked with strict tsc against @aws-sdk/client-apigatewaymanagementapi, @aws-sdk/lib-dynamodb and @types/aws-lambda (SDK 3.1142.0) in September 2026, and run against aws-sdk-client-mock: a paged scan of three connections, one gone, one too large.

How does PostToConnection reach a WebSocket client?

Clients connect with wss://; the backend talks to the same API over HTTPS. The API Gateway developer guide documents three @connections operations, all signed with SigV4:

HTTP request SDK v3 command What it does
POST /{stage}/@connections/{id} PostToConnectionCommand Sends a message to the client
GET /{stage}/@connections/{id} GetConnectionCommand Returns ConnectedAt, LastActiveAt and the client’s source IP and user agent
DELETE /{stage}/@connections/{id} DeleteConnectionCommand Disconnects the client

The client’s endpoint is the part before /@connections: https://{api-id}.execute-api.{region}.amazonaws.com/{stage}. Inside a route handler you can build it from event.requestContext.domainName and stage. With a custom domain name, AWS’s own example drops the stage, so use https://{your-domain} plus whatever base path you mapped. On the browser side nothing changes; the message arrives as a normal message event on the WebSocket API.

Prerequisites

  • A deployed WebSocket API with $connect, $disconnect and a custom route (the examples use sendmessage), each integrated with Lambda.
  • Node.js 18 or later with @aws-sdk/client-apigatewaymanagementapi, @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb; @types/aws-lambda for event types.
  • A DynamoDB table WsConnections with partition key connectionId and time to live enabled on expiresAt.

How to send API Gateway WebSocket messages with PostToConnection in SDK v3

  1. Save the connection ID on $connectWrite requestContext.connectionId to DynamoDB with an expiry. Don’t send messages from $connect itself; the connection isn’t established until the handler returns.
  2. Remove it on $disconnectAPI Gateway calls this route on a best-effort basis, so also plan for IDs it never cleans up.
  3. Create one client per endpointnew ApiGatewayManagementApiClient({ endpoint }), reused across invocations.
  4. Send bytesEncode JSON with TextEncoder and pass it as Data. Check the size first: the payload limit is 128 KB.
  5. Handle GoneExceptionDelete the ID and carry on; it’s normal, not an error.

Example: store connection IDs on $connect and $disconnect

connections.ts

// connections.ts: $connect and $disconnect route handlers that keep a DynamoDB table of live connection IDs.
// Table "WsConnections": partition key connectionId (string), TTL attribute expiresAt (epoch seconds).
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb";
import type { APIGatewayProxyWebsocketEventV2, APIGatewayProxyResultV2 } from "aws-lambda";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.CONNECTIONS_TABLE ?? "WsConnections";
const MAX_CONNECTION_SECONDS = 2 * 60 * 60; // API Gateway closes WebSocket connections after 2 hours

export async function onConnect(event: APIGatewayProxyWebsocketEventV2): Promise<APIGatewayProxyResultV2> {
  const { connectionId, domainName, stage } = event.requestContext;
  const now = Math.floor(Date.now() / 1000);
  await ddb.send(
    new PutCommand({
      TableName: TABLE,
      Item: {
        connectionId,
        callbackUrl: `https://${domainName}/${stage}`,
        connectedAt: now,
        expiresAt: now + MAX_CONNECTION_SECONDS + 300, // TTL sweeps rows whose $disconnect never arrived
      },
    }),
  );
  return { statusCode: 200 };
}

export async function onDisconnect(event: APIGatewayProxyWebsocketEventV2): Promise<APIGatewayProxyResultV2> {
  await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { connectionId: event.requestContext.connectionId } }));
  return { statusCode: 200 };
}

The TTL is the safety net. API Gateway closes every WebSocket connection after 2 hours, so a row older than that can’t belong to a live client, even if $disconnect never ran. DynamoDB’s time to live deletes expired rows within a few days without consuming write throughput; until then, GoneException handling removes any the broadcast trips over. The guide to querying DynamoDB with AWS SDK v3 covers the table design if you later add rooms or user IDs as keys.

Example: send, clean up GoneException and broadcast

ws-send.ts

// ws-send.ts: send messages to WebSocket clients with PostToConnectionCommand, remove stale
// connection IDs on GoneException (HTTP 410), and fan out to many clients with a concurrency limit.
import {
  ApiGatewayManagementApiClient,
  PostToConnectionCommand,
  GoneException,
  PayloadTooLargeException,
  LimitExceededException,
} from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, DeleteCommand, paginateScan } from "@aws-sdk/lib-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.CONNECTIONS_TABLE ?? "WsConnections";
const MAX_PAYLOAD_BYTES = 128 * 1024; // API Gateway WebSocket message payload quota

// The endpoint is the connection URL with https:// instead of wss://, including the stage:
// https://{api-id}.execute-api.{region}.amazonaws.com/{stage}. With a custom domain, use https://{domain}
// plus the base path you mapped, if any.
const clients = new Map<string, ApiGatewayManagementApiClient>();
function clientFor(endpoint: string): ApiGatewayManagementApiClient {
  let client = clients.get(endpoint);
  if (!client) {
    client = new ApiGatewayManagementApiClient({ endpoint, region: process.env.AWS_REGION });
    clients.set(endpoint, client);
  }
  return client;
}

export type SendResult = "sent" | "gone" | "too-large" | "failed";

/** Send one JSON message to one connection. A 410 means the client is gone: delete its row. */
export async function sendToConnection(endpoint: string, connectionId: string, message: unknown): Promise<SendResult> {
  const data = new TextEncoder().encode(JSON.stringify(message));
  if (data.byteLength > MAX_PAYLOAD_BYTES) return "too-large";
  try {
    await clientFor(endpoint).send(new PostToConnectionCommand({ ConnectionId: connectionId, Data: data }));
    return "sent";
  } catch (err) {
    if (err instanceof GoneException) {
      await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { connectionId } }));
      return "gone";
    }
    if (err instanceof PayloadTooLargeException) return "too-large";
    if (err instanceof LimitExceededException) throw err; // let the caller slow down or retry later
    console.error(`postToConnection ${connectionId}:`, err);
    return "failed";
  }
}

/** Run tasks with at most `limit` in flight. */
async function withConcurrency<T>(items: T[], limit: number, task: (item: T) => Promise<void>): Promise<void> {
  let next = 0;
  const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) {
      const item = items[next++];
      await task(item);
    }
  });
  await Promise.all(workers);
}

/** Broadcast to every stored connection. Scans the table, so keep it for small audiences. */
export async function broadcast(endpoint: string, message: unknown, concurrency = 25): Promise<Record<SendResult, number>> {
  const counts: Record<SendResult, number> = { sent: 0, gone: 0, "too-large": 0, failed: 0 };
  const ids: string[] = [];
  for await (const page of paginateScan({ client: ddb }, { TableName: TABLE, ProjectionExpression: "connectionId" })) {
    for (const item of page.Items ?? []) ids.push(String(item.connectionId));
  }
  await withConcurrency(ids, concurrency, async (id) => {
    counts[await sendToConnection(endpoint, id, message)]++;
  });
  return counts;
}

And the route handler that ties it together:

chat-route.ts

// chat-route.ts: handler for a custom "sendmessage" route. Clients send {"action":"sendmessage","text":"..."}.
import type { APIGatewayProxyWebsocketEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { broadcast, sendToConnection } from "./ws-send.js";

export async function onSendMessage(event: APIGatewayProxyWebsocketEventV2): Promise<APIGatewayProxyResultV2> {
  const { connectionId, domainName, stage } = event.requestContext;
  const endpoint = `https://${domainName}/${stage}`; // default execute-api domain; see the custom domain note
  let text = "";
  try {
    text = String(JSON.parse(event.body ?? "{}").text ?? "").slice(0, 2000);
  } catch {
    await sendToConnection(endpoint, connectionId, { type: "error", error: "body must be JSON" });
    return { statusCode: 400 };
  }
  const counts = await broadcast(endpoint, { type: "message", from: connectionId, text, at: new Date().toISOString() });
  console.log(JSON.stringify({ route: "sendmessage", ...counts }));
  return { statusCode: 200 };
}

Four details worth copying:

  • Typed errors. The SDK exports GoneException (410), PayloadTooLargeException (413), LimitExceededException (429) and ForbiddenException (403) as classes, so instanceof works instead of matching strings.
  • Bytes, not objects. Data is a blob: a Uint8Array, Buffer or string. Encoding once also gives you an exact byteLength to check.
  • Bounded concurrency. Firing 5,000 sends with Promise.all invites throttling and exhausts sockets. Twenty-five workers pulling from one list keeps the rate steady; tune it with your own tests.
  • Reuse clients. The client map lives outside the handler, so warm Lambda invocations skip client setup. Reducing AWS SDK v3 bundle size in Lambda covers the cold-start side.

Coming from v2? The v2 call was new AWS.ApiGatewayManagementApi({ endpoint }).postToConnection({ ConnectionId, Data }).promise(). The free AWS SDK v2 to v3 converter drafts the change, and the client-apigatewaymanagementapi package in the SDK repository lists every command.

Which limits and prices apply to WebSocket messages?

Quota (API Gateway WebSocket APIs) Value Adjustable
Message payload size 128 KB No
WebSocket frame size 32 KB No
Connection duration 2 hours No
Idle connection timeout 10 minutes No
New connections per second per account per Region 500 Yes

The quota page notes that a message larger than 32 KB must be split into frames of 32 KB or less, that this applies to @connections commands, and that a larger frame closes the connection with code 1009. Keeping messages under 32 KB avoids the question entirely; send an S3 link for anything bigger.

As of September 2026, the AWS Price List for API Gateway in us-east-1 (published 21 September 2026) charges $1.00 per million messages for the first billion a month, then $0.80, plus $0.25 per million connection minutes. AWS meters messages in 32 KB increments, so a 33 KB message counts as two. Worked example: 2,000 clients connected 8 hours a day for 30 days is 2,000 × 480 × 30 = 28.8 million connection minutes, or $7.20. Broadcasting one 2 KB update a minute to all of them is 2,000 × 480 × 30 = 28.8 million messages, or $28.80, assuming each server-to-client message is metered like a client message (the pricing page doesn’t separate them). Send less often, or only to clients that need the update, and the message line shrinks first.

Permissions needed

The function that sends messages needs execute-api:ManageConnections on the @connections resource of your API and stage, plus access to the connections table:

websocket-sender-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PostToWebSocketClients",
      "Effect": "Allow",
      "Action": "execute-api:ManageConnections",
      "Resource": "arn:aws:execute-api:us-east-1:123456789012:abc123/prod/POST/@connections/*"
    },
    {
      "Sid": "ConnectionsTable",
      "Effect": "Allow",
      "Action": ["dynamodb:PutItem", "dynamodb:DeleteItem", "dynamodb:Scan"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/WsConnections"
    }
  ]
}

Use GET or DELETE in place of POST in the ARN if the function also calls GetConnection or DeleteConnection. Split the policy per function in production: the $connect handler needs only PutItem. To derive actions from code, see finding the IAM actions your SDK for JavaScript code needs or the IAM policy generator for TypeScript. Who may open a connection in the first place is a separate question; the audit to find API Gateway methods without authorization covers the REST side of it.

Troubleshooting and common mistakes

  • GoneException on every call. You’re posting from the $connect handler (the connection isn’t established yet), the client already disconnected, or the endpoint points at a different stage than the one the client joined.
  • ForbiddenException or 403. The role lacks execute-api:ManageConnections for that API and stage, or the ARN in the policy names a different API ID or stage than the endpoint. Then work through troubleshooting IAM access denied errors.
  • Works on the default domain, fails on the custom one. Check the base path mapping and drop the stage from the endpoint when the domain maps to the stage directly.
  • Clients drop after 10 minutes. That’s the idle timeout. Have the client send a small message on a timer, and reconnect after the 2-hour limit.
  • Slow broadcasts or LimitExceededException. Lower the concurrency, retry later with backoff (the AWS SDK v3 retries and timeouts guide shows the settings), or queue the work.
  • No logs for failed routes. Turn on stage logging first; finding API Gateway stages without logging shows which stages lack it.

How do you test PostToConnection code?

Mock the clients, not the network. This test drives the module above through a paged scan, a gone client and an oversized message:

ws-send.test.ts

// ws-send.test.ts: exercises ws-send.ts with aws-sdk-client-mock (run with: npx tsx test/ws-send.test.ts)
import assert from "node:assert/strict";
import { mockClient } from "aws-sdk-client-mock";
import { ApiGatewayManagementApiClient, PostToConnectionCommand, GoneException, PayloadTooLargeException } from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBDocumentClient, ScanCommand, DeleteCommand, PutCommand } from "@aws-sdk/lib-dynamodb";
import { broadcast, sendToConnection } from "../src/ws-send.ts";
import { onConnect } from "../src/connections.ts";

const api = mockClient(ApiGatewayManagementApiClient);
const ddb = mockClient(DynamoDBDocumentClient);
const endpoint = "https://abc123.execute-api.us-east-1.amazonaws.com/prod";

ddb.on(ScanCommand).resolvesOnce({ Items: [{ connectionId: "A" }, { connectionId: "B" }], LastEvaluatedKey: { connectionId: "B" } })
  .resolvesOnce({ Items: [{ connectionId: "C" }] });
ddb.on(DeleteCommand).resolves({});
ddb.on(PutCommand).resolves({});
api.on(PostToConnectionCommand).resolves({});
api.on(PostToConnectionCommand, { ConnectionId: "B" }).rejects(new GoneException({ message: "gone", $metadata: { httpStatusCode: 410 } }));
api.on(PostToConnectionCommand, { ConnectionId: "C" }).rejects(new PayloadTooLargeException({ message: "big", $metadata: { httpStatusCode: 413 } }));

const counts = await broadcast(endpoint, { hello: "world" }, 2);
assert.deepEqual(counts, { sent: 1, gone: 1, "too-large": 1, failed: 0 });
assert.equal(ddb.commandCalls(DeleteCommand).length, 1);
assert.deepEqual(ddb.commandCalls(DeleteCommand)[0].args[0].input.Key, { connectionId: "B" });
const sent = api.commandCalls(PostToConnectionCommand)[0].args[0].input;
assert.equal(new TextDecoder().decode(sent.Data as Uint8Array), '{"hello":"world"}');

assert.equal(await sendToConnection(endpoint, "A", { big: "x".repeat(140 * 1024) }), "too-large");

const res = await onConnect({ requestContext: { connectionId: "Z", domainName: "abc123.execute-api.us-east-1.amazonaws.com", stage: "prod" } } as never);
assert.deepEqual(res, { statusCode: 200 });
assert.equal(ddb.commandCalls(PutCommand)[0].args[0].input.Item?.callbackUrl, endpoint);
console.log("ws-send tests passed", counts);

It runs with npx tsx ws-send.test.ts. The guide to mocking AWS SDK v3 clients in unit tests shows the same setup in Jest and Vitest.

Limits of this approach

  • A table scan per broadcast is fine for hundreds of clients, not for hundreds of thousands. Add a room or topic key and query it, or fan out through a queue; sending and receiving SQS messages with SDK v3 is a good building block.
  • A successful call means API Gateway accepted the message for that connection. If the application needs delivery confirmation, have the client acknowledge.
  • There’s no server-side list of connections to query. Your table is the source of truth, so keep it clean.
  • Messages are capped at 128 KB and billed per 32 KB. Large payloads belong in S3.

Frequently asked questions

What endpoint does ApiGatewayManagementApiClient need?

https://{api-id}.execute-api.{region}.amazonaws.com/{stage}, the WebSocket URL with https instead of wss. With a custom domain, use the domain and its base path mapping.

What does GoneException mean in API Gateway WebSocket?

The connection ID doesn’t belong to a live connection: the client disconnected, or you posted before the connection was established. Delete the ID from your store and move on.

How large can a PostToConnection message be?

The message payload quota is 128 KB, with 32 KB frames. Larger calls fail with PayloadTooLargeException.

Can I send a message from the $connect route?

Not to the connecting client. The connection is established after $connect returns, so post from another route or after the client’s first message.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud