Real-time data with AppSync subscriptions

How to send a notification for data changes

Author's image
Tamás Sallai
11 mins

AppSync subscriptions allow you to push events to clients in real-time when a change happened. This is great for applications that show data that can change without user interaction, which is the case for almost all applications. With subscriptions, a change is pushed to clients so they don't need to poll for new data constantly.

This is done by keeping a WebSocket channel open from the clients to AppSync. When there is an event, AppSync can use this channel to push the notifications out to currently connected users.

In the implementation AppSync provides no magic bullet to detect and push changes. That means you'll need to specify which events trigger the notifications and who to send out to. All AppSync does is that it keeps track of interested clients and manages the WS connections to them.

In this article, we'll look into the components that you'll need to configure to set-up real-time notifications and what are the pitfalls along the way. Get ready for a bumpy ride---a lot of things are needed to get right to make this work and most of them don't make much sense.

Real-time notifications

But before we dive into AppSync, let's step back and look into notifications in general!

Real-time notification, no matter the architecture, require an event. When that event happens, the notification process can go ahead and ping the clients.

The most important thing is to identify the event that will trigger the notification. It can be anything that the underlying system can detect changes to, but most of the time it's something that changes a query's result.

For example, when an entry in a database changes a query that touches that entry returns different results. In a simple data model we'll use in this article we have users and todo items and clients can query the todos for a user. When a new todo item is added it changes the result of the getTodos query.

In this case, the event trigger should be the addition of the new item so that clients know that their view of the database is out-of-date. But not just in this case: also when a todo item is removed or changed. This shows why real-time notifications are hard: you need to make sure that anything that touches these items trigger an event. Even if one is missed, clients will see a different reality than what is in the database and a hard reload is the only course of corrective action they can do.

When events are hooked into the notification flow, a different problem emerges: there are too many events. This is where filtering comes into play. This is to notify only the clients that need to be notified and not everybody.

This is important for two reasons. The first is performance. Sending a notification might be an expensive operation on the backend-side as there might be a lot of clients listening for that event. And it can be expensive on the client-side as getting too many notifications consume bandwidth and CPU.

And the second is security. If a client gets an event for an item it is not supposed to have read permissions for, that is information leakage. As a rule of thumb, a client should only get events for data it has access to.

Subscriptions in AppSync

Now that we have a good understanding of what is needed for real-time data, let's see how it works in AppSync!

Subscriptions are tied to Mutations, as they are the only things that can change data (Queries are for retrievals only). To define which mutations trigger which subscriptions you need to add a directive in the schema.

For example, the addTodo mutation triggers the newTodo subscription:

type Todo {
	id: ID!
	name: String!
	checked: Boolean!
	created: AWSDateTime!
}

type Subscription {
	newTodo: Todo
	@aws_subscribe(mutations: ["addTodo"])
}

type Mutation {
	addTodo(userId: ID!, name: String!): Todo!
}

type Query {
}

schema {
	query: Query
	mutation: Mutation
	subscription: Subscription
}

Notice that the mutation returns a Todo!, but the subscription's return value is optional (Todo). This is a requirement in AppSync and subscriptions fail otherwise.

This takes care of the events part of the notification flow and this is enough to see subscription events in action.

Testing subscriptions

To test subscriptions, the easiest way is to use the Management Console on two tabs: one for the subscription and one for the mutation.

The subscription is a GraphQL query that starts with subscription, but otherwise it looks like any other mutation/query:

subscription MySubscription {
	newTodo {
		checked
		created
		id
		name
	}
}

Then send the mutation:

mutation MyMutation {
	addTodo(name: "todo5", userId: "user1") {
		id
		checked
		created
		name
	}
}

Then the event is triggered:

{
	"data": {
		"newTodo": {
			"checked": false,
			"created": "2022-01-17T11:08:56.576Z",
			"id": "f3ea79b6-8689-45be-8dae-47805776750c",
			"name": "todo5"
		}
	}
}

Under the hood, the subscription tab opens a WebSocket connection and the event comes through that:

Sounds easy, right? Let's move on to see where it gets complicated!

Subscription event fields

Notice that subscriptions are similar to other queries: they can define the structure they want and the events will contain only those fields. This is great as the clients define what data they are interested in, which is in-line with the GraphQL philosophy. If a client needs to show the checked status, it can specify that in the request without any changes on the backend.

But there is a catch, at least in AppSync. Only the fields the mutation specified will be available for the client-side query and every other field will be null. This means the mutation needs to ask for all the fields any subscription will get, even if the mutation itself does not need it.

As an illustration, this mutation does not get the checked field:

mutation MyMutation {
	addTodo(name: "todo5", userId: "user1") {
		id
		created
		name
	}
}

Because of this, a subscription that asks for that will now throw an error:

{
	"data": {
		"newTodo": null
	},
	"errors": [
		{
			"message": "Cannot return null for non-nullable type: 'Boolean' within parent 'Todo' (/newTodo/checked)",
			"path": [
				"newTodo",
				"checked"
			]
		}
	]
}

Why this error?

The schema defined this field as required:

type Todo {
	id: ID!
	name: String!
	checked: Boolean!
	created: AWSDateTime!
	user: User!
}

But since the mutation did not request it, it is null, which is non-conformant for the schema. If this field would be optional (no ! in the end), the field would be null, no matter the database value.

This behavior can lead to surprises, as it's not necessarily straightforward that the mutation's result value defines the subscription events' result values.

Filtering

The newTodo subscription gets all the events for all todo items. Especially when many users are using the API simultaneously, this can be a problem. This is when filtering is useful.

AppSync supports event filtering via subscription arguments. For example, let's say you want to get only events with certain names. The clients could specify "Shopping" or "Work" and only get events when the Todo object's name field is that value. To do this, add an argument:

type Subscription {
	newTodo(name: String): Todo
	@aws_subscribe(mutations: ["addTodo"])
}

This argument is optional, so a client can choose to provide it. If it does, the filter is active, if it's missing then it's not active and AppSync sends all events to the client.

subscription MySubscription {
	newTodo(name: "Shopping") {
		checked
		created
		id
		name
	}
}

This mutation sends an event:

mutation MyMutation {
	addTodo(name: "Shopping", userId: "user1") {
		id
		created
		name
	}
}

But this does not:

mutation MyMutation {
	addTodo(name: "Work", userId: "user1") {
		id
		created
		name
	}
}

Sounds simple, but the devil is in the details. Let's see what fields a client can filter on!

As it turns out, a subscription can only filter on the return value's immediate fields. Here, the mutation returns a Todo object and that has a name, so the subscription can support filtering on name. But let's say the Todo also has a User field:

type Todo {
	id: ID!
	name: String!
	checked: Boolean!
	created: AWSDateTime!
	user: User!
}

While the return value has a User, it is not possible to filter events by user. And since it's usually more important to filter by a container object, especially for new items, it's tricky to implement.

One way is to add a user_id which is a scalar and not a complex type. But a better idea is to add a separate mutation that does nothing else than trigger a subscription. This way, you can add other fields without polluting the object model. We'll cover this in the next section.

Pattern: Notify mutation

So, let's abandon the "it's just an extra directive" marketing line and implement something that is actually useful!

We've identified 2 flaws in AppSync subscriptions:

  • Subscriptions get the mutation's result fields
  • Filtering works only on top-level fields of the result

To solve both of these, we need to leave the idea that it's enough to trigger a subscription using a directive behind. Instead, let's separate the state change (the original mutation) and the subscription trigger!

For the latter, we can define a different structure that can contain all the fields we want to allow filtering on:

type TodoEvent {
	userId: ID!
	groupId: ID!
	todo: Todo!
}

Then add a mutation that gets a todo item ID and returns this type:

type Mutation {
	addTodo(userId: ID!, name: String!): Todo!
	notifyTodo(id: ID!): TodoEvent!
}

Finally, the subscription can be triggered by the new mutation:

type Subscription {
	todo(userId: ID, groupId: ID): TodoEvent
	@aws_subscribe(mutations: ["notifyTodo"])
}

These are the basics of the solution and it solves both problems:

  • The notifyTodo is responsible for returning all the fields that will be available for the subscriptions
  • The TodoEvent contains all the fields that can be used in filters

Let's move on to the implementation!

Implementation

The notifyTodo is not a real mutation in a sense that it does not modify data, but it triggers the subscriptions. Its implementation is simple: it gets a todo ID and it needs to get all the data from the database for the TodoEvent type. In this example, I'll use DynamoDB to store data, so all queries will be DynamoDB operations.

First, it needs to get the todo item:

resource "aws_appsync_function" "Mutation_notifyTodo_1" {
	# ...
	data_source = aws_appsync_datasource.todos.name

	request_mapping_template = <<EOF
{
	"version" : "2018-05-29",
	"operation" : "GetItem",
	"key" : {
		"id": {"S": $util.toJson($ctx.args.id)}
	},
	"consistentRead" : true
}
EOF

	response_mapping_template = <<EOF
#if ($ctx.error)
	$util.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson($ctx.result)
EOF
}

Then get the user object:

resource "aws_appsync_function" "Mutation_notifyTodo_2" {
	# ...
	data_source = aws_appsync_datasource.users.name

	request_mapping_template = <<EOF
{
	"version" : "2018-05-29",
	"operation" : "GetItem",
	"key" : {
		"id": {"S": $util.toJson($ctx.prev.result.userid)}
	},
	"consistentRead" : true
}
EOF
}

Then transform the data into the TodoEvent format:

{
	response_mapping_template = <<EOF
#if ($ctx.error)
	$util.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson({"userId": $ctx.prev.result.userid, "groupId": $ctx.result.groupid, "todo": $ctx.prev.result})
EOF
}

When this mutation is called, it triggers the subscribers. The next step is to call it when a todo item is added, which is part of the addTodo mutation.

For a mutation to call another mutation we need to configure a HTTP data source (if you need to call a mutation from a Lambda function, see this article):

resource "aws_appsync_datasource" "notifyTodo" {
	api_id           = aws_appsync_graphql_api.appsync.id
	name             = "notifyTodo"
	service_role_arn = aws_iam_role.appsync.arn
	type             = "HTTP"
	http_config {
		endpoint = regex("^[^/]+//[^/]+", aws_appsync_graphql_api.appsync.uris["GRAPHQL"])
		authorization_config {
			aws_iam_config {
				signing_region = data.aws_region.current.name
				signing_service_name = "appsync"
			}
		}
	}
}

The endpoint is the AppSync API domain, and it uses AWS's Signature V4. This means the role it uses needs to have access to the mutation itself:

data "aws_iam_policy_document" "appsync" {
	statement {
		actions = [
			"appsync:GraphQL",
		]
		resources = [
			"${aws_appsync_graphql_api.appsync.arn}/types/Mutation/fields/notifyTodo"
		]
	}
}

The last thing is to call the mutation in a resolver:

resource "aws_appsync_function" "Mutation_addTodo_3" {
	# ...
	data_source = aws_appsync_datasource.notifyTodo.name
	request_mapping_template = <<EOF
{
	"version": "2018-05-29",
	"method": "POST",
	"params": {
		"query": {},
		"headers": {
			"Content-Type" : "application/json"
		},
		"body": $util.toJson({
			"query": "mutation notifyTodo($id: ID!) {
				notifyTodo(id: $id) {
					userId
					groupId
					todo {
						id
						name
						checked
						created
					}
				}
			}",
			"operationName": 'notifyTodo',
			"variables": {"id": $ctx.prev.result.id}
		})
	},
	"resourcePath": "/graphql"
}
EOF

	response_mapping_template = <<EOF
#set($result = $util.parseJson($ctx.result.body))
#if ($result.errors)
	$util.error($result.errors[0].message)
#end
$util.toJson($ctx.prev.result)
EOF
}

This sends an HTTP POST request, signed with the role's credentials, calling the notifyTodo mutation, passing the todo item's ID, and finally defining the result structure that subscribers can get.

Conclusion

Subscriptions in AWS AppSync provide a fully managed way to notify clients of updates in real-time. It takes the burden of managing connections and handling sending out the notification events so you don't need to think about capacity scaling and keeping track of who is listening. You just need to get the implementation right.

But it has a lot of rough edges and nasty surprises along the way. Event structure and filtering are the two main areas that work in a counterintuitive way, and without understanding how they work exactly it's easy to get it wrong.

March 9, 2022
In this article