skip to content

With an Amazon API Gateway WebSocket API, how does your backend send a message to a client after that client has connected, and what must happen on the `$connect` route for it to be possible?

level: middleimportance: should knowfreq 40%

answer

  1. the socket lives in the gateway, not your code
  2. connectionId is the handle to keep
  3. store it on connect, in a table
  4. push through the @connections callback URL
  5. 410 means clean up the record

basics

~20 s

API Gateway gives every connection a connectionId, which your $connect handler must persist (typically in DynamoDB) alongside the user it belongs to. The backend then pushes by calling PostToConnection on the API's @connections management endpoint, needing the execute-api:ManageConnections permission.

solid answer

~40 s

A WebSocket API routes incoming frames to backends using a route selection expression, with three reserved routes: `$connect`, `$disconnect`, and `$default`. On `$connect`, the event carries `requestContext.connectionId` — an opaque handle for that live connection — and it is your job to store it, mapped to whatever identity or topic the connection cares about, usually in DynamoDB. To push later, the backend calls the **ApiGatewayManagementApi** `PostToConnection` operation against the API's callback URL, `https://{api-id}.execute-api.{region}.amazonaws.com/{stage}/@connections/{connectionId}`, and the caller's IAM role needs `execute-api:ManageConnections`. If the connection has since gone away, that call returns `GoneException` (HTTP 410), which is your signal to delete the stored record. Treat `$disconnect` as best-effort — it is not guaranteed to fire — so the 410 path is the real cleanup mechanism, not a nicety.

code

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

export const handler = async (event) => {
  const { domainName, stage } = event.requestContext;
  const client = new ApiGatewayManagementApiClient({
    endpoint: `https://${domainName}/${stage}`
  });

  for (const id of await connectionIdsForRoom("general")) {
    try {
      await client.send(new PostToConnectionCommand({
        ConnectionId: id,
        Data: JSON.stringify({ type: "message", text: "hello" })
      }));
    } catch (err) {
      if (err.name === "GoneException") await deleteConnection(id);
      else throw err;
    }
  }
  return { statusCode: 200 };
};

go deeper

for a junior

Know that API Gateway holds the connection and hands your code a connectionId, and that pushing later means calling back into the API with that id rather than replying to a request.

for a middle

Explain the reserved $connect/$disconnect/$default routes, storing the connectionId at connect time, and calling PostToConnection on the @connections callback URL with the ManageConnections permission.

for a senior

Show that you design for stale connections: 410 means delete-and-continue, $disconnect is best-effort, and fan-out needs bounded concurrency plus a store shaped for room or user lookups.

for a principal

Weigh whether a persistent-connection design is warranted at all against server-sent events or polling, and own the cost and operational model of a connection registry that must stay consistent as the fleet scales.

## The shape of a WebSocket API A WebSocket API is the third API Gateway type and behaves differently from request/response APIs. The client opens one long-lived connection; individual messages then arrive as frames that API Gateway must dispatch to a backend. It does that with a **route selection expression**, by default `$request.body.action`: if the client sends `{"action": "sendMessage", ...}`, the `sendMessage` route's integration is invoked. Three routes are reserved: - **`$connect`** — fires once when the connection is established. Authorization happens here (this is the only route where a request-based authorizer can inspect the handshake), and returning a non-2xx response rejects the connection outright. - **`$disconnect`** — fires, on a best-effort basis, when the connection closes. - **`$default`** — catches frames that match no route, including messages whose body is not the shape the selection expression expects. ## Why server push needs bookkeeping With HTTP, a response goes back down the same request. With WebSockets the server may need to speak first, and API Gateway holds the socket, not your code. The bridge is the **connectionId**: an opaque identifier for one live connection, present on every route's event at `requestContext.connectionId`, along with `requestContext.domainName` and `requestContext.stage`. Because your compute is stateless — a Lambda that handled `$connect` is long gone by the time a push is needed — the id has to be **persisted externally**. DynamoDB is the conventional store: partition by connectionId for direct sends, plus an item or index keyed by user id or room so you can fan out to "everyone in channel X". Whatever you choose, the record must be written in `$connect` and removed when the connection dies. ## Making the push The backend calls `PostToConnection` on the **API Gateway Management API**, aimed at the callback URL for that API and stage: ``` https://{api-id}.execute-api.{region}.amazonaws.com/{stage}/@connections/{connectionId} ``` The same `@connections` resource supports `GET` for connection metadata and `DELETE` to force a disconnect. Two things must be in place: 1. **IAM permission.** The calling role needs `execute-api:ManageConnections` on the API's ARN. A role that can only be invoked *by* the API does not automatically get to call *back into* it — a frequent oversight, and it fails with a 403, not a 410. 2. **The right endpoint.** Build the client against the callback URL, not the default SDK endpoint. Handlers usually derive it from `requestContext.domainName` and `requestContext.stage`, which also keeps it correct behind a custom domain. ## Handling the dead connection The crucial operational detail: `$disconnect` is **best-effort**. A client that vanishes — laptop lid closed, mobile radio dropped — may never produce that event, so your table will accumulate ids that no longer exist. `PostToConnection` on such an id returns **`GoneException` / HTTP 410**, and the correct response is to delete the record and carry on. A fan-out that treats 410 as a hard error will fail whole broadcasts because one stale subscriber left. Distinguish it from 403 (missing `ManageConnections`) and from a payload-too-large rejection. ```javascript import { ApiGatewayManagementApiClient, PostToConnectionCommand } from "@aws-sdk/client-apigatewaymanagementapi"; try { await client.send(new PostToConnectionCommand({ ConnectionId: id, Data: JSON.stringify(payload) })); } catch (err) { if (err.name === "GoneException") await deleteConnection(id); else throw err; } ``` ## Limits that shape the design API Gateway caps a WebSocket message payload at 128 KB (transmitted in 32 KB frames), holds a connection for a maximum of two hours, and closes idle connections after ten minutes. Those three numbers drive real design decisions: send large results by reference (put the object in S3 and push a presigned URL) rather than inline; implement a client-side ping so an idle-but-wanted connection survives; and make reconnection a normal, expected event in the client, with the application state resynchronised on reconnect rather than assumed intact. ## When not to use it If the client only needs occasional server-initiated updates and never sends much upstream, server-sent events or plain polling can be far less machinery — no connection table, no fan-out logic, no reconnect protocol. Choose a WebSocket API when the interaction is genuinely bidirectional and latency-sensitive, and be honest in an interview that the connection registry is the part teams underestimate.

  • How do you broadcast one message to every client in a chat room?
    Query your connection store by room id to get the connectionIds, then call PostToConnection for each — in parallel, with a bounded concurrency. Treat GoneException per connection as a delete-and-continue rather than a failure of the whole broadcast. For very large rooms, fan the work out over a queue or multiple invocations so one function is not iterating tens of thousands of sends.
  • Where is a WebSocket connection authorized, and why there?
    On the `$connect` route, which is the only point where the handshake's headers and query string are available to a request-based authorizer. Returning a non-2xx there refuses the connection. After the handshake there are no per-frame HTTP headers to inspect, so any later authorization has to be application-level using identity you captured at connect time and stored with the connectionId.
  • A client is disconnected roughly every ten minutes despite an open session. What is happening?
    That is the idle timeout: API Gateway closes a WebSocket connection with no traffic for ten minutes, and separately enforces a two-hour maximum connection duration. Add an application-level ping from the client to keep an intentionally idle connection alive, and treat reconnection as normal — the client should resync state on reconnect rather than assume the session survived.

saying these in an interview costs you the question

  • Assumes the Lambda can keep the socket open between invocations
  • Relies on $disconnect always firing to clean up
  • Treats a 410 GoneException as a broadcast-wide failure
  • Forgets execute-api:ManageConnections on the pushing role
  • Believes a WebSocket connection lasts indefinitely

context