DEV Community

Cover image for Serverless Chat on AWS with AppSync Events
Marko Djakovic for AWS Community Builders

Posted on • Originally published at marko.dj

Serverless Chat on AWS with AppSync Events

In my previous article I've shown how easy it can be to create a real-time chat application using AWS IoT Core. As I mention there, I'd like to explore implementing the same solution using a recently announced AppSync Event API. In this article, I will do just that. Along the way, we will discuss the similarities and the differences between the two approaches, while covering the basics of AppSync Events, architecture choices, caveats and more. Same like last time, I will include a CDK project so you can play with it on your own using a simple client to test it live.

AppSync Events

AppSync Events was announced in October 2024 and it really sounds exciting. I won't bother you much with repeating what is already said about the service itself, so in a nutshell, citing the AWS guide:

AWS AppSync Events lets you create secure and performant serverless WebSocket APIs that can broadcast real-time event data to millions of subscribers, without you having to manage connections or resource scaling.

I immediately wanted to try it out, but I quickly realized it is still "young" and lacks a lot of features that would be necessary for a lot of use cases. For example, currently there is no direct way to store the messages from channels anywhere. I expected that you'd be able to at least trigger a Lambda function and do something custom with the messages, like storing them to DynamoDB. Message processing is possible via namespace handlers by using onPublish and onSubscribe handlers, however since they run on AppSync's JavaScript runtime there is no possibility to do much more than on the fly processing or filtering of the payload.

In this article, I'll mainly focus on working around the lack of persistence capabilities. A great re:Invent 2024 session contains a detailed presentation of the service, addressing the drawbacks and suggesting alternative solutions. The session is very informative and also addresses the roadmap, according to which all the missing features (and more) are planned to be delivered. Now, I would like to dig in the event persistence possibilities with AppSync Events.

Events Persistence Issue

In the re:Invent session shared above, the presenter addresses the event persistence issue at around 44:35, presenting workarounds as "Advanced patterns". The workaround is basically having an API next to the AppSync Event API that will be used to persist events separately. This can look something like:

Proposed architecture

Since the side API would be there anyway for fetching previous messages, adding one more endpoint for storing messages is not a big deal. Relatively straightforward, it will work, but there's a catch. This way the client is burdened with making two requests when publishing messages - one towards the AppSync Event API to publish it to subscribers, and one to the side API that will actually persist the event to the database. If there's an issue with the API persisting the events, there will be inconsistencies between what is persisted and what the subscribers get. Since the client has to send events to two destinations and both need to succeed, handling retries in case of failures is adding complexity as well. I would rather call this an anti-pattern.

A Look at an Alternative Solution

I would like to present an alternative approach to this challenge. Besides the WebSockets endpoint, AppSync Event API has an HTTP endpoint which was highlighted as a neat feature to integrate not only frontend clients, but also backends that could publish events to it. To achieve consistency and a more robust solution, leveraging DynamoDB Streams and EventBridge Pipes can be the way to go. This way the client makes only one request when sending messages, and the subscribers always get the messages that are actually persisted. Effectively, this means that the client does not use the HTTP endpoint of AppSync Events at all, but relies on the side API for sending messages, and only listens to incoming messages via the realtime endpoint. The diagram below shows this architecture more clearly.

Alternative architecture

This is possible by leveraging some of many EventBridge capabilities. Namely, DynamoDB Streams are activated on the messages table, streaming each new message through an EventBridge Pipe to an API Destination, connected to the actual HTTP endpoint of AppSync Events.

Since I'm a big fan of CDK, and like to be on the bleeding-edge, I am relying on a few alpha modules from CDK: @aws-cdk/aws-pipes-alpha, @aws-cdk/aws-pipes-sources-alpha, @aws-cdk/aws-pipes-targets-alpha and this is what it takes to connect with DynamoDB Streams:

// EventBridge Pipe DynamoDB Stream Source
const dynamoDbStreamSource = new DynamoDBSource(table, {
    startingPosition: DynamoDBStartingPosition.LATEST,
});

// EventBridge API Destination
const appSyncEventsApiDestination = new ApiDestination(this, 'AppSyncEventsApiDestination', {
    connection: appSyncEventsApiConnection,
    endpoint: appSyncEventsApiEndpoint,
    httpMethod: HttpMethod.POST,
});

// EventBridge Pipe with API Destination Target & Input Transformation
const pipe = new Pipe(this, 'EBPipe', {
    source: dynamoDbStreamSource,
    target: new ApiDestinationTarget(appSyncEventsApiDestination, {
        inputTransformation: InputTransformation.fromObject({
            channel: 'serverlesschat/channels/' + '<$.dynamodb.NewImage.channel.S>',
            events: [
                JSON.stringify({
                    channel: '<$.dynamodb.NewImage.channel.S>',
                    timestamp: '<$.dynamodb.NewImage.timestamp.S>',
                    username: '<$.dynamodb.NewImage.username.S>',
                    message: '<$.dynamodb.NewImage.message.S>',
                })
            ],
        }),
        // api key must be sent with each message
        headerParameters: {
            'X-Api-Key': appSyncEventsApiKey,
        }
    }),
});
Enter fullscreen mode Exit fullscreen mode

Input transformation is performed to accommodate DynamoDB Streams to a format required by AppSync Events endpoint:

{
  "channel": "string",
  "events": ["..."]
}
Enter fullscreen mode Exit fullscreen mode

Note: events is an array of strings, hence json messages are stringified during transformation.

Pro tip: activating CloudWatch logs for the Pipe with Log execution data turned on can save you a bunch of time troubleshooting.

Trade-offs

As with any architecture decision, there are trade-offs, so let's just address them with regard to adding DynamoDB Streams and an EventBridge Pipe. To improve consistency and simplify client communication, the speed of message delivery is reduced. What does this mean? There is a slight latency noticed in message delivery to subscribed clients. This is not surprising since the message goes
through the API Gateway and a Lambda function, to a DynamoDB table and from there it is streamed to the AppSync Event API via an EventBridge Pipe, which is basically another HTTP call. So, a lot of stuff happening in a few dozen lines of code. I just can't help but notice that the IoT Core solution is slightly snappier overall. This will of course be solved when AppSync Events gets two-way WebSocket communication.

Authorization

A few words on authorization. The easiest way to get started with AppSync Events is to use API Key auth, and that's exactly how this project is configured. Other modes are supported as well, pretty standard for AWS: Cognito User Pool, IAM, OIDC, and custom AWS Lambda. These choices offer great flexibility for various authorization needs, especially given the fact that it can be configured per namespace.

Authorization with EventBridge API Destination

As seen in the above CDK code snipped, an API Destination is configured with a Connection to support authorization when sending messages towards the AppSync Event HTTP API endpoint. Additionally, the destination target configured in EventBridge Pipe is required to have X-Api-Key header sent with each request for the messages to get through. What I noticed is that once this header is missing, or the value is incorrect - the Connection becomes Deauthorized automatically and the whole Pipe stops working as a result. I haven't found a way to fix that, but just to destroy and deploy the stack again. I am not sure what it takes to authorize the API Destination again, but it was rather inconvenient from developer experience standpoint.

Demo & Code

Similar as in the last article, I created a simple HTML client powered by some JavaScript to test & demo this solution. Generally, examples on configuring the client use Amplify, which is a neat library when building with SPA frameworks. I opted for a plain HTML page which uses native browser fetch and websocket APIs, without any external dependencies. You don't have to worry about the technicalities if you just want to run the example yourself, however if you'd like to dive deeper on how to connect to AppSync Events without Amplify, feel free to consult Understanding the Event API WebSocket protocol AWS guide and my code.

https://github.com/imflamboyant/serverless-aws-chat

AWS Deployment and usage instructions are provided in the repository's README file.

Conclusions

In this article I have shared my experience in building a familiar real-time chat solution, this time with a brand-new AWS offering - AppSync Events. It does feel similar to IoT Core, mostly due to the way subscriptions are organized with namespaces/channels, but I must admit it gives a more "native" developer experience.

The roadmap shared at re:Invent 2024 sounds promising, stating that we can expect features like two-way websocket communication, data persistence options, and Lambda handlers, among others. When all that gets released, I truly believe this will be one of the most important AWS services for building modern applications.

Top comments (0)