WebSocket API: Pushing Click Counts to a Realtime Dashboard
The click counter is now accurate, but the dashboard still has to ask the server again to learn the new numbers. The "number jumps the instant someone hits a link" experience needs the reverse direction: the server pushes data down to the browser. HTTP request-response can't do that because the client has to ask first. WebSocket keeps a two-way connection open, and that's what we build in this article.
Goal
Stand up an API Gateway WebSocket API with $connect and $disconnect routes, store each connection in DynamoDB tied to the link it watches, then have the aggregator push the new click count down to exactly the open connections. We open a real connection, click the link, and watch the number get pushed back. WebSocket API bills per connection-minute and per message, and a small amount of testing is negligible.
How WebSocket differs from HTTP API
With an HTTP API, each request is an independent round: client asks, server answers, connection closes. The server has no way to proactively send anything to the client afterward. WebSocket flips the model: the client opens a connection and it stays open, both sides sending messages at any time. API Gateway manages those connections and calls Lambda when a connection event occurs.
A WebSocket API has three kinds of routes. $connect runs when a client opens the connection, $disconnect runs when it closes, and custom routes run based on the content of messages the client sends. We use $connect to remember who is watching which link, and $disconnect to forget when they leave. Pushing data down is not a route; it's a reverse API call from the server side, covered in the aggregator section.
Remembering connections in DynamoDB
When a connection opens, API Gateway issues it a connectionId. For the server to push data later, it has to remember that id, and remember which link this connection is watching. The $connect handler reads code from the query string at connect time and stores it in the single table:
const connId = event.requestContext.connectionId;
const code = event.queryStringParameters?.code;
if (!code) return { statusCode: 400, body: "thieu code" };
await ddb.send(new PutCommand({
TableName: TABLE,
Item: {
PK: `CONN#${connId}`, SK: "META", code,
GSI1PK: `WSCODE#${code}`, GSI1SK: `CONN#${connId}`,
ttl: Math.floor(Date.now() / 1000) + 7200,
},
}));
The primary key CONN#<connId> lets $disconnect delete the connection when it only knows the connectionId. And GSI1PK = WSCODE#<code> reuses the same index built in Article 05, so the aggregator can find every connection watching a link with one query. The two-hour TTL is a safety net: if for some reason $disconnect doesn't run, the connection record still expires on its own instead of being stuck forever.
The $disconnect handler just needs to delete by connectionId:
await ddb.send(new DeleteCommand({
TableName: TABLE,
Key: { PK: `CONN#${event.requestContext.connectionId}`, SK: "META" },
}));
Pushing data down: the reverse call
When the aggregator finishes counting a click, it finds the connections watching that link then sends the new number down to each. This send doesn't go through a WebSocket route but through a separate connection-management API, ApiGatewayManagementApi, pointed at the WebSocket API's own endpoint:
const mgmt = new ApiGatewayManagementApiClient({ endpoint: process.env.WS_ENDPOINT });
export async function pushClickUpdate(code: string): Promise<void> {
const meta = await ddb.send(new GetCommand({ TableName: TABLE, Key: linkKey(code) }));
const clicks = (meta.Item?.clicks as number) ?? 0;
const conns = await ddb.send(new QueryCommand({
TableName: TABLE, IndexName: "GSI1",
KeyConditionExpression: "GSI1PK = :p",
ExpressionAttributeValues: { ":p": `WSCODE#${code}` },
}));
const data = Buffer.from(JSON.stringify({ code, clicks }));
await Promise.all((conns.Items ?? []).map(async (c) => {
const connId = (c.GSI1SK as string).replace("CONN#", "");
try {
await mgmt.send(new PostToConnectionCommand({ ConnectionId: connId, Data: data }));
} catch (e) {
const err = e as { name?: string };
if (err.name === "GoneException") {
await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `CONN#${connId}`, SK: "META" } }));
}
}
}));
}
One important practical detail: a connection may already be dead before we know it (browser closed abruptly, network dropped). When sending to such a connection, API Gateway returns GoneException (HTTP 410). We catch that error and delete the dead connection record so we don't send into the void next time. The aggregator calls pushClickUpdate(code) right after the counting transaction succeeds.
GET /{code} ─▶ resolve ─PutEvents─▶ EventBridge ─▶ SQS ─▶ aggregator
│ 1. counting transaction
│ 2. query GSI1 WSCODE#<code>
│ 3. postToConnection per connId
▼
browser ◀═══ WebSocket (kept open) ═══ WebSocketApi ◀──┘
(click count jumps) {code, clicks}
Permissions and endpoint
The aggregator needs two new things: an address to send back to, and permission to send. Both are declared in the template:
Environment:
Variables:
WS_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref Table
- Statement:
- Effect: Allow
Action: execute-api:ManageConnections
Resource: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${WebSocketApi}/*"
execute-api:ManageConnections is the permission that allows PostToConnection. The endpoint uses the https scheme, not wss, because this is a management API call rather than opening another WebSocket.
Real test
Open a WebSocket connection to wss://.../prod?code=<code> with a small client, then click the link three times, a few seconds apart each. The client prints each message it receives:
WS OPEN
WS RECV {"code":"XzVZoih","clicks":4}
WS RECV {"code":"XzVZoih","clicks":5}
WS RECV {"code":"XzVZoih","clicks":6}
This link already had 3 clicks beforehand, so the number starts at 4. Each time the link is clicked somewhere else, the new click count is pushed down to the open connection almost instantly, with no request from the client side asking. This is the kind of communication HTTP request-response can't do: the server proactively sends down. A real dashboard just needs to open this connection on page load and update the number each time it receives a message, never polling.
🧹 Cleanup
# delete the link + STAT items + leftover connection records
aws dynamodb scan --table-name url-shortener --query 'Items[].{PK:PK.S,SK:SK.S}' --output text | \
while read pk sk; do
aws dynamodb delete-item --table-name url-shortener \
--key "{\"PK\":{\"S\":\"$pk\"},\"SK\":{\"S\":\"$sk\"}}"
done
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username ws@example.com
Keep the stack for the next article.
Wrap-up
The dashboard is now realtime. The WebSocket API keeps the connection open, $connect and $disconnect remember and forget connections in DynamoDB via the same single table and GSI1, and the aggregator pushes the new click count down to exactly the connections watching that link with PostToConnection. Dead connections are cleaned up on GoneException. The whole chain from a click to the number jumping on screen now runs without anyone having to ask the server again.
The event-driven core is nearly done. There's one kind of processing we haven't touched: multi-step workflows with ordering, waiting, and branching, the kind that gets tangled and hard to read if crammed into a single Lambda function. The next article uses Step Functions to orchestrate such a workflow, taking the example of moderating a link before it goes live, and discusses the saga pattern for undoing when a middle step fails.