Every serverless team eventually gets the request: "can the UI update live?" A dashboard that refreshes itself, a chat window, a job-progress bar, tokens streaming out of an LLM. The default answer — poll a REST endpoint every three seconds — works until you count the invocations, and then it is both slow and expensive.
This tutorial covers the two real-time options we actually deploy for clients on Serverless Framework V4: API Gateway WebSocket APIs for bidirectional traffic, and Lambda response streaming for one-way token or progress delivery. They solve different problems, and picking the wrong one is the most common mistake we see.
Which one do you need?
| Requirement | Use |
|---|---|
| Client sends messages after connecting (chat, collaborative editing, subscriptions) | WebSocket API |
| Server pushes to many clients from a backend event (dashboards, notifications) | WebSocket API |
| One request, one long response streamed back (LLM tokens, large reports) | Lambda response streaming via Function URL |
| Occasional updates, tolerant of 10–30s latency | Do not build either — poll, or use EventBridge to a webhook |
If a single HTTP request produces the whole stream and the client never talks back, response streaming is dramatically less machinery: no connection table, no disconnect handling, no @connections calls. Reach for WebSockets only when you genuinely need the return channel or fan-out.
Part 1: A WebSocket API on Serverless Framework V4
A WebSocket API has three built-in routes — $connect, $disconnect, $default — plus any custom routes you dispatch on. The framework wires all of them from the events block.
service: realtime-api
frameworkVersion: '4'
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
region: eu-west-1
websocketsApiName: ${self:service}-${sls:stage}
websocketsApiRouteSelectionExpression: $request.body.action
environment:
CONNECTIONS_TABLE: !Ref ConnectionsTable
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:DeleteItem
- dynamodb:Query
Resource:
- !GetAtt ConnectionsTable.Arn
- !Sub '${ConnectionsTable.Arn}/index/*'
- Effect: Allow
Action: execute-api:ManageConnections
Resource: !Sub 'arn:aws:execute-api:${aws:region}:${aws:accountId}:*/${sls:stage}/POST/@connections/*'
functions:
connect:
handler: src/connect.handler
events:
- websocket:
route: $connect
authorizer:
name: wsAuthorizer
identitySource:
- 'route.request.querystring.token'
disconnect:
handler: src/disconnect.handler
events:
- websocket: $disconnect
subscribe:
handler: src/subscribe.handler
events:
- websocket:
route: subscribe
default:
handler: src/default.handler
events:
- websocket: $default
wsAuthorizer:
handler: src/authorizer.handler
Note the route selection expression: $request.body.action means a client sending {"action":"subscribe","topic":"orders"} is routed to the subscribe function. Without it, everything lands in $default.
Authenticate on $connect, not later
Browsers cannot set headers on a WebSocket handshake, so the usual pattern is a short-lived token in the query string, validated by a REQUEST authorizer. Authorize once at $connect — after the handshake, API Gateway will not re-invoke the authorizer, so anything you need later (user ID, tenant, plan tier) must be persisted with the connection.
// src/authorizer.js
import { verifyToken } from './auth.js';
export const handler = async (event) => {
const token = event.queryStringParameters?.token;
try {
const claims = await verifyToken(token); // JWKS-verified, cached
return {
principalId: claims.sub,
policyDocument: {
Version: '2012-10-17',
Statement: [{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: event.methodArn }],
},
context: { userId: claims.sub, tenantId: claims.tenant },
};
} catch {
throw new Error('Unauthorized'); // → 401 on the handshake
}
};
Issue that token from your existing REST API with a lifetime of a minute or two. It ends up in browser history and proxy logs; treat it as a one-shot handshake credential, never a long-lived session token.
The connection table
Connection IDs are meaningless on their own. You need a table that maps connection → user/tenant/topics so you can fan out later, and a TTL so dead rows disappear.
resources:
Resources:
ConnectionsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- { AttributeName: pk, AttributeType: S }
- { AttributeName: sk, AttributeType: S }
- { AttributeName: topic, AttributeType: S }
KeySchema:
- { AttributeName: pk, KeyType: HASH }
- { AttributeName: sk, KeyType: RANGE }
GlobalSecondaryIndexes:
- IndexName: byTopic
KeySchema:
- { AttributeName: topic, KeyType: HASH }
- { AttributeName: sk, KeyType: RANGE }
Projection: { ProjectionType: KEYS_ONLY }
TimeToLiveSpecification:
AttributeName: expiresAt
Enabled: true
// src/connect.js
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb';
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const handler = async (event) => {
const { connectionId } = event.requestContext;
const { userId, tenantId } = event.requestContext.authorizer;
await ddb.send(new PutCommand({
TableName: process.env.CONNECTIONS_TABLE,
Item: {
pk: `TENANT#${tenantId}`,
sk: `CONN#${connectionId}`,
connectionId, userId, tenantId,
topic: 'none',
expiresAt: Math.floor(Date.now() / 1000) + 26 * 60 * 60, // idle+max lifetime + slack
},
}));
return { statusCode: 200 };
};
API Gateway enforces a 10-minute idle timeout and a 2-hour maximum connection duration. Both are hard limits. Your client must send a ping every few minutes and be prepared to reconnect with exponential backoff and jitter when the two-hour cap is hit. Build reconnection into the client from day one — retro-fitting it after a production incident is no fun.
$disconnect is best-effort: it usually fires, but not always. That is why the TTL attribute exists, and why every send path must handle a stale connection.
Pushing messages out
Any Lambda — including ones triggered by DynamoDB Streams, EventBridge, or SQS — can push to a connection using ApiGatewayManagementApi:
// src/broadcast.js
import { ApiGatewayManagementApiClient, PostToConnectionCommand }
from '@aws-sdk/client-apigatewaymanagementapi';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb';
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const api = new ApiGatewayManagementApiClient({ endpoint: process.env.WS_ENDPOINT });
export const handler = async (event) => {
const { tenantId, payload } = JSON.parse(event.detail ?? event.body);
const { Items = [] } = await ddb.send(new QueryCommand({
TableName: process.env.CONNECTIONS_TABLE,
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :c)',
ExpressionAttributeValues: { ':pk': `TENANT#${tenantId}`, ':c': 'CONN#' },
}));
await Promise.allSettled(Items.map(async (item) => {
try {
await api.send(new PostToConnectionCommand({
ConnectionId: item.connectionId,
Data: Buffer.from(JSON.stringify(payload)),
}));
} catch (err) {
if (err.name === 'GoneException') {
await ddb.send(new DeleteCommand({
TableName: process.env.CONNECTIONS_TABLE,
Key: { pk: item.pk, sk: item.sk },
}));
} else {
throw err;
}
}
}));
};
Three things this snippet gets right and most first drafts get wrong:
GoneExceptionis normal. A client closing its laptop lid produces one. Delete the row and move on; do not fail the whole batch.Promise.allSettled, notPromise.all. One dead connection should not stop 400 healthy ones from receiving the update.- Fan-out has a ceiling. Serial-ish
PostToConnectioncalls from one Lambda work up to a few hundred connections. Past that, shard: publish to SNS/EventBridge and let a fan-out function handle a slice of the connection table each, or batch connections into SQS messages.
Set WS_ENDPOINT from the deployed API rather than hardcoding it:
environment:
WS_ENDPOINT: !Sub 'https://${WebsocketsApi}.execute-api.${aws:region}.amazonaws.com/${sls:stage}'
What it costs
WebSocket APIs bill on connection-minutes plus messages: roughly $0.25 per million messages and $0.25 per million connection-minutes in us-east-1. 1,000 concurrently connected users for an 8-hour workday is about 480,000 connection-minutes — around 12 cents a day, before Lambda. Compare that to 1,000 browsers polling a REST endpoint every 3 seconds: 9.6 million requests a day, most of them returning "nothing changed". Real-time is usually the cheaper option; that surprises people.
Part 2: Lambda response streaming, when you do not need a return channel
If a client makes one request and wants output as it is produced, skip the connection table entirely. A Lambda Function URL with RESPONSE_STREAM invoke mode streams bytes back over plain HTTP, works with fetch() and EventSource, and has no 6 MB response cap (it supports up to 20 MB payloads and a 15-minute function timeout).
functions:
report:
handler: src/report.handler
timeout: 300
url:
invokeMode: RESPONSE_STREAM
cors: true
authorizer: aws_iam # or none + your own token check inside the handler
// src/report.js
export const handler = awslambda.streamifyResponse(async (event, responseStream) => {
const stream = awslambda.HttpResponseStream.from(responseStream, {
statusCode: 200,
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
});
try {
for await (const chunk of generateRows(event)) {
stream.write(`data: ${JSON.stringify(chunk)}\n\n`);
}
stream.write('event: done\ndata: {}\n\n');
} catch (err) {
stream.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
} finally {
stream.end();
}
});
Caveats worth knowing before you promise this to a product owner:
- Streaming is supported on Function URLs and direct SDK invokes, not on API Gateway REST or HTTP APIs. If you need an API Gateway custom domain in front, use CloudFront with the Function URL as an origin (and
OriginAccessControlwith Lambda signing so the URL is not publicly callable). - Errors that happen after the first byte cannot change the status code — you already sent
200. Emit an in-band error event, as above, and make the client handle it. - Buffering: bytes are flushed once the first 256 KB has accumulated or the handler yields; for token-by-token output, write small chunks frequently rather than assembling a big string.
- Time-to-first-byte is what users perceive. Send a heartbeat comment (
:\n\n) immediately so proxies keep the connection open.
Local development and testing
WebSocket handlers are ordinary functions with an odd event shape, so unit-test them directly with a fixture containing requestContext.connectionId and requestContext.authorizer. For integration tests, deploy an ephemeral stage per branch (serverless deploy --stage pr-482), connect with the ws package from a test runner, assert on received frames, then serverless remove. Mocking execute-api locally is more trouble than it is worth; ephemeral stages cost cents.
Give each stage its own connections table (it comes free if you let the framework name it per-stage) so a test run cannot broadcast into someone else's session.
Operational checklist
- Client reconnect logic with exponential backoff and jitter — otherwise a redeploy stampedes every client at once.
- Idempotent message handling: clients will occasionally receive a duplicate after a reconnect. Include a monotonic sequence or event ID in every payload.
- Alarm on
execute-api5XX and onGoneExceptionrate spikes — a sudden jump usually means clients are being disconnected, not that users left. - Structured logs with
connectionIdanduserIdon every handler; support tickets are unanswerable without them. - Cap connections per tenant if you are multi-tenant. One misbehaving client with a reconnect loop can open thousands.
Related reading
- Observability for serverless in 2026 — the log fields that make WebSocket debugging possible.
- Building an LLM-powered API on Lambda and Bedrock — the streaming pattern applied to model output.
- Deploying a REST API with Serverless Framework V4 — the request/response counterpart to this stack.
Need real-time in production, not in a prototype? SleekDeploy designs and builds WebSocket and streaming architectures on the Serverless Framework — connection management, auth, fan-out at scale, and the load testing to prove it holds. Talk to us about Serverless Architecture Design or get in touch.