This post is written by Alexey Paramonov, Solutions Architect, ISV.
This blog post demonstrates how to reconnect anonymous users to WebSocket API without losing their session context. You learn how to link WebSocket API connection IDs to the logical user, what to do when the connection fails, and how to store and recover session information when the user reconnects.
WebSocket APIs are common in modern interactive applications. For example, for watching stock prices, following live chat feeds, collaborating with others in productivity tools, or playing online games. Another example described in “Implementing reactive progress tracking for AWS Step Functions” blog post uses WebSocket APIs to send progress back to the client.
The backend is aware of the client’s connection ID to find the right client to send data to. But if the connection temporarily fails, the client reconnects with a new connection ID. If the backend does not have a mechanism to associate a new connection ID with the same client, the interaction context is lost and the user must start over again. In a live chat feed that could mean skipping to the most recent message with a loss of some previous messages. And in case of AWS Step Functions progress tracking the user would lose progress updates.
The sample uses Amazon API Gateway WebSocket APIs. It uses AWS Lambda for WebSocket connection management and for mocking a teleprinter to generate a stateful stream of characters for testing purposes. There are two Amazon DynamoDB tables for storing connection IDs and user sessions, as well as an optional MediaWiki API for retrieving an article.
The following diagram outlines the architecture:
A teleprinter returns data one character at a time instead of a single response. When the Lambda function fetches an article, it feeds it character by character inside the for-loop with a delay of 100ms on every iteration. This demonstrates how the user can continue reading the article after reconnecting and not starting the feed all over again. The pattern could be useful for traffic limiting by slowing down user interactions with the backend.
When the user connects to the WebSocket API, the client generates a user ID and saves it in the browser’s session storage. The user ID is a random string. When the client opens a WebSocket connection, the frontend sends the user ID inside the Sec-WebSocket-Protocol header, which is standard for WebSocket APIs. When using the JavaScript WebSocket library, there is no need to specify the header manually. To initialize a new connection, use:
const newWebsocket = new WebSocket(wsUri, userId);
wsUri is the deployed WebSocket API URL and userId is a random string generated in the browser. The userId goes to the Sec-WebSocket-Protocol header because WebSocket APIs generally offer less flexibility in choosing headers compared to RESTful APIs. Another way to pass the user ID to the backend could be a query string parameter.
The backend receives the connection request with the user ID and connection ID. The WebSocket API generates the connection ID automatically. It’s important to include the Sec-WebSocket-Protocol in the backend response, otherwise the connection closes immediately:
return { 'statusCode': 200, 'headers': { 'Sec-WebSocket-Protocol': userId } }
The Lambda function stores this information in the DynamoDB Connections table:
ddb.put\_item( TableName=table\_name, Item={ 'userId': {'S': userId}, 'connectionId': {'S': connection\_id}, 'domainName': {'S': domain\_name}, 'stage': {'S': stage}, 'active': {'S': True} } )
The active attribute indicates if the connection is up. In the case of inactivity over a specified time limit, the OnDelete Lambda function deletes the item automatically. The Put operation comes handy here because you don’t need to query the DB if the user already exists. If it is a new user, Put creates a new item. If it is a reconnection, Put updates the connectionId and sets active to True again.
The primary key for the Connections table is userId, which helps locate users faster as they reconnect. The application relies on a global secondary index (GSI) for locating connectionId. This is necessary for marking the connection inactive when WebSocket API calls OnDisconnect function automatically.
Now the application has connection management, you can retrieve session data. The Teleprinter Lambda function receives a connection ID from WebSocket API. You can query the GSI of Connections table and find if the user exists:
def get\_user\_id(connection\_id): response = ddb.query( TableName=connections\_table\_name, IndexName='connectionId-index', KeyConditionExpression='connectionId = :c', ExpressionAttributeValues={ ':c': {'S': connection\_id} } ) items = response['Items'] if len(items) == 1: return items[0]['userId']['S'] return None
Another DynamoDB table called Sessions is used to check if the user has an existing session context. If yes, the Lambda function retrieves the cursor position and resumes teletyping. If it is a brand-new user, the Lambda function starts sending characters from the beginning. The function stores a new cursor position if the connection breaks. This makes it easier to detect stale connections and store the current cursor position in the Sessions table.
Try: api\_client.post\_to\_connection( ConnectionId=connection\_id, Data=bytes(ch, 'utf-8') ) except api\_client.exceptions.GoneException as e: print(f"Found stale connection, persisting state") store\_cursor\_position(user\_id, pos) return { 'statusCode': 410 } def store\_cursor\_position(user\_id, pos): ddb.put\_item( TableName=sessions\_table\_name, Item={ 'userId': {'S': user\_id}, 'cursorPosition': {'N': str(pos)} } )
After this, the Teleprinter Lambda function ends.
The same mechanism also works for authenticated users. The main difference is it takes a given ID from a JWT token or other form of authentication and uses it instead of a randomly generated user ID. The backend relies on unambiguous user identification and may require only minor changes for handling authenticated users.
The OnDisconnect function marks user connections as inactive and adds a timestamp to ‘lastSeen’ attribute. If the user never reconnects, the application should purge inactive items from Connections and Sessions tables. Depending on your operational requirements, there are two options.
The sample application uses EventBridge to schedule OnDelete function execution every 5 minutes. The following code shows how AWS SAM adds the scheduler to the OnDelete function:
OnDeleteSchedulerFunction: Type: AWS::Serverless::Function Properties: Handler: app.handler Runtime: python3.9 CodeUri: handlers/onDelete/ MemorySize: 128 Environment: Variables: CONNECTIONS\_TABLE\_NAME: !Ref ConnectionsTable SESSIONS\_TABLE\_NAME: !Ref SessionsTable Policies: - DynamoDBCrudPolicy: TableName: !Ref ConnectionsTable - DynamoDBCrudPolicy: TableName: !Ref SessionsTable Events: Schedule: Type: ScheduleV2 Properties: ScheduleExpression: rate(5 minute)
The Events section of the function definition is where AWS SAM sets up the scheduled execution. Change values in ScheduleExpression to meet your scheduling requirements.
The OnDelete function relies on the GSI to find inactive user IDs. The following code snippet shows how to query connections with more than 5 minutes of inactivity:
five\_minutes\_ago = int((datetime.now() - timedelta(minutes=5)).timestamp()) stale\_connection\_items = table\_connections.query( IndexName='lastSeen-index', KeyConditionExpression='active = :hk and lastSeen < :rk', ExpressionAttributeValues={ ':hk': 'False', ':rk': five\_minutes\_ago } )
After that, the function iterates through the list of user IDs and deletes them from the Connections and Sessions tables:
# remove inactive connections with table\_connections.batch\_writer() as batch: for item in inactive\_users: batch.delete\_item(Key={'userId': item['userId']}) # remove inactive sessions with table\_sessions.batch\_writer() as batch: for item in inactive\_users: batch.delete\_item(Key={'userId': item['userId']})
The sample uses batch\_writer() to avoid requests for each user ID.
DynamoDB provides a built-in mechanism for expiring items called Time to Live (TTL). This option is easier to implement. With TTL, there’s no need for EventBridge scheduler, OnDelete Lambda function and additional GSI to track time span. To set up TTL, use the ‘lastSeen’ attribute as an object creation time. TTL deletes or archives the item after a specified period of time. When using AWS SAM or AWS CloudFormation templates, add TimeToLiveSpecification to your code.
The TTL typically deletes expired items within 48 hours. If your operational requirements demand faster and more predictable timing, use option 1. For example, if your application aggregates data while the user is offline, use option 1. But if your application stores a static session data, like cursor position used in this sample, option 2 can be an easier alternative.
Ensure you can manage AWS resources from your terminal.
You can find the source code and README here.
The repository contains both the frontend and the backend code. To deploy the sample, follow this procedure:
git clone https://github.com/aws-samples/websocket-sessions-management.git sam build && sam deploy --guided With the backend deployed, test it using the hosted frontend.
You can watch an animated demo here.
Notice that the app has generated a random user ID on startup. The app shows the user ID above in the header.
To clean up the resources, in the root directory of the repository run:
sam delete
This removes all resources provisioned by the template.yml file.
In this post, you learn how to keep track of user sessions when using WebSockets API and not lose the session context when the user reconnects again. Apply learnings from this example to improve your user experience when using WebSocket APIs for web-frontend and mobile applications, where internet connections may be unstable.
For more serverless learning resources, visit Serverless Land.