A quick, hands-on demo 2018 was the year I won many imaginary arguments about why a serverless WebSocket API Gateway will never work. I was . wrong Before I atone, let me explain: serverless patterns work best when state is separated from execution logic, but I argued that the two are inseparable in real-time applications, pertaining to connection state. So when the team announced WebSocket support at re:Invent, I was eager to learn more. API Gateway Let’s refactor a simple real-time app into a serverless real-time app and deploy it to a WebSocket API Gateway. Hello Hello We begin with a simple NodeJS client that talks to wss://echo.websocket.org: WebSocket = ( ); readline = ( ); url = process.argv[ ]; ws = WebSocket(url); ws.on( , () => .log( )); ws.on( , data => .log( )); ws.on( , () => { .log( ); process.exit(); }); readline.createInterface({ : process.stdin, : process.stdout, }).on( , data => { ws.send(data); }); const require 'ws' const require 'readline' const 2 const new 'open' console 'connected' 'message' console `From server: ` ${data} 'close' console 'disconnected' input output 'line' View code snippets on Github I’ve also prepared a demo : repo git clone https: cd ws-demos/echo npm install node client wss: # Once the process starts, type something and press enter connected > happy From server: happy //github.com/robzhu/ws-demos //echo.websocket.org 2019 2019 If we want to implement the echo server functionality locally: WebSocket = ( ); wss = WebSocket.Server({ : }); wss.on( , socket => { socket.on( , data => { socket.send(data); }); }); .log( ); const require 'ws' const new port 8080 'connection' 'message' console `Listening on ws://localhost:8080` How would we prepare this code for use with API Gateway? Those ten lines of code are actually doing several things: View code snippets on Githu Accepting incoming websocket connections Listening for incoming messages Giving the message handler access to the source socket, on which to send data back to the client (Implicitly) handling client disconnect If we pasted this code into a serverless function, it would need to run constantly to handle incoming connection requests, which violates the on-demand nature of the serverless functions. However, if we isolate the inner echo logic as a serverless function, we would need to provide some way for it to communicate with the WebSockets held by the API Gateway. Since the serverless function is stateless, the object reference we have in the function closure above will not suffice. Rather, we need a serializable token that represents the connection, let’s call it “connectionId”: WebSocket = ( ); short = ( ); connections = {}; send = { connection = connections[connectionId]; connection.send(data); } defaultActions = { : { id = short.generate(); connection.connectionId = id connections[id] = connection; .log( ); customActions.connect && customActions.connect(id); }, : { connections[connectionId]; .log( ); customActions.disconnect && customActions.disconnect(connectionId); }, : { customActions.default ? customActions.default(connectionId) : send(connectionId, message ? : ) }, }; customActions = { : { send(connectionId, data); } }; wss = WebSocket.Server({ : }); wss.on( , socket => { defaultActions.connect(socket); socket.on( , messageJson => { .log( ); { { action, data } = .parse(messageJson); customHandler = customActions[action]; customHandler ? customHandler(socket.connectionId, data) : defaultActions.default(socket.connectionId, { action, data }); } (ex) { .error(ex); socket.send( ); } }); socket.on( , () => { defaultActions.disconnect(socket.connectionId); }); }); .log( ); const require 'ws' const require 'short-uuid' const const ( ) => connectionId, data const const connect ( ) => connection const console `client connected with connectionId: ` ${id} disconnect ( ) => connectionId delete console `client disconnected with connectionId: ` ${connectionId} default ( ) => connectionId, message `unrecognized action: ` ${message.action} `message cannot be empty` const echo ( ) => connectionId, data const new port 8080 'connection' 'message' console `Received: ` ${messageJson} try const JSON // call the matching custom handler, else call the default handler const catch console `Bad Request format, use: '{"action": ..., "data": ...}'` 'close' console `Listening on ws://localhost:8080` View code snippets on Github In the process of isolating the function logic, we’ve also separated the responsibilities of the API Gateway, albeit as an oversimplified example (here’s a of how API Gateway actually works). echo video On lines 10–28, we’re defining three special default action handlers: , , and (which handles any messages that aren’t explicitly defined in ). connect disconnect default customActions The custom action handler now takes and as explicit function arguments, both of which are strings. The only external dependency is , which we will come back to shortly echo connectionId data send We’ve also changed the protocol to require that the client sends requests resembling {“action”: …, “data”: …}. That’s just a one line (18) change: WebSocket = ( ); readline = ( ); url = process.argv[ ]; ws = WebSocket(url); ws.on( , () => .log( )); ws.on( , data => .log( )); ws.on( , () => { .log( ); process.exit(); }); readline.createInterface({ : process.stdin, : process.stdout, }).on( , data => { message = .stringify({ : , : input}); ws.send(data); }); const require 'ws' const require 'readline' const 2 const new 'open' console 'connected' 'message' console `From server: ` ${data} 'close' console 'disconnected' input output 'line' const JSON action 'echo' data View code snippets on Github To test the newly refactored server and client: # Terminal Tab A node serverWithActions # Terminal Tab B, note protocol, not node clientWithActions ws: 'ws' 'wss' //localhost:8080 Now we’re ready to create the WebSocket API in API Gateway. Log into AWS, (I tested this demo in the North Virginia region, but it should work in other regions). Open , click “Create API”, select WebSocket and fill in the following settings: AWS API Gateway Double check that the Route Selection Expression is “$request.body.action”, as this expression tells API Gateway how to determine which action to invoke. Next, click “Create API”, and you should see the Routes page. Add “echo” as a “New Route Key” and click the check button: In a new browser tab, open the , click “Create Function”, then select “Author from scratch” Lambda AWS Console Give your function a name like “WSDemoEchoHandler”, and select an IAM role. If you don’t have an existing IAM role for your Lambda functions, choose “Create a custom role” and the role will suffice. lambda_basic_execution The WebSocket API is still very new, so we need to patch in a bit of functionality to the existing module. In the embedded code editor, right click on your function folder and select “New File”: aws-sdk Name it “patch.js” and paste in the contents of . Next, open “index.js” and paste the following: this file AWS = ( ); ( ); send = ; { apigwManagementApi = AWS.ApiGatewayManagementApi({ : , : event.requestContext.domainName + + event.requestContext.stage }); send = (connectionId, data) => { apigwManagementApi.postToConnection({ : connectionId, : }).promise(); } } exports.handler = (event) => { init(event); connectionId = event.requestContext.connectionId; data = .parse(event.body).data send(connectionId, data); {}; }; const require 'aws-sdk' // apply the patch require './patch.js' let undefined ( ) function init event const new apiVersion '2018-11-29' endpoint '/' async await ConnectionId Data `Echo: ` ${data} async const let JSON await // the return value is ignored when this function is invoked from WebSocket gateway return View code snippets on Github Remember earlier when the local implementation of the echo function depended on the “send” function? Now we can see how it’s implemented via the aws-sdk. Save the Lambda and return to the API Gateway console tab. In the “Lambda Function” field, enter the name of the Lambda function we just created: This way, the API Gateway knows to invoke WSDemoEchoHandler function in response to a request to the echo route”. Note that “route” does not refer to an HTTP route. Once the route is hooked up, we’re ready to deploy our API: “ We need to define a new Deployment Stage: Once deployed, you’ll see the Stage Editor, which shows the URL for your new WebSocket API: Copy the text of the “WebSocket URL” field at the top. It will look like: wss://something.execute-api.us-east-1.amazonaws.com/prod . Back in our terminal window, we can test against the new WebSocket API: node clientWithActions.js wss: meow From server: Echo: meow //something.execute-api.us-east-1.amazonaws.com/prod That’s it for now, but there are there are a few followup topics to explore: What happens during API redeployments and updates to the Lambda function while the connection is still active? Building a real app with fanout An interview with the API Gateway team and architecture discussion. Do those topics sound interesting? Did you get stuck? Leave a comment or email me (robzhu at amazon dot com). Thanks to Randall Hunt.