Matthew Krick

@matt.krick

The Hybrid Strategy for GraphQL Subscriptions

Subscriptions are really hard. That’s what makes them fun. It’s been a few months since GraphQL & Relay have supported them, and still examples are sparse. The worst part? The ones that do exist use patterns that will give you a headache if you try to implement them in your largescale app. Trust me.

After a whole lot of trial and error, I finally came across a pattern that scales linearly with your app. Hopefully, it’ll keep you from suffering as you embark on real-timeifying your app.

Some Background

The holy grail is a page where every piece of domain state updates in real-time, the code is maintainable, and there is no overfetching. That’s as true today as it has been since the first AJAX request. After all, pseudo-real-time apps have been around for decades. You send an AJAX every 5 seconds for everything on the screen and you’re there. The problem is that when it really counts, 5 seconds is too long, and when activity is sparse, 5 seconds is too short. Subscriptions fixed this because instead of dry humping the server ad infinitum, the server tells you what’s changed.

Meteor was the first framework to really leverage this using what’s called a LiveQuery. It tailed the MongoDB Oplog and pushed the changes to subscribed clients. RethinkDB later perfected this by building a natively reactive database. Both strategies are good, but suffer from 2 problems:

First, the overfetching: If you care about the content of a Todo and someone changes the priority, you’re still handling the whole gosh darn document. Sure you could pluck individual fields before you send to the client, but then that subscription cannot be reused across the app, and from there it’s overfetching the whole way down.

Second, and most importantly, they’re limited by how the data is stored. If you care about a Team as well as the number of Todos they are have, you either need to denormalize that count onto the Team table, or you need to also subscribe to a Todo count and patch the 2 together. Back-end changes to accommodate the front-end? That’s a code smell.

Enter GraphQL

GraphQL changed all this with their newfangled data-transform pipelines. It can even be used for a LiveQuery like those mentioned above, although I don’t recommend it (to learn why, I highly recommend the awesome talk from GraphQL Summit). With GraphQL, a subscription is no longer limited by how it is stored in a database because each subscription triggers a client-defined query. The only problem left to solve is how to segment subscriptions. After talking to a lot of folks, reading a lot of code, and trying a lot of stupid things, I learned there are 3 ways to segment subscriptions: per-query, per-entity, and per-mutation. Let’s look at each.

Per-query Subscriptions

The siren of the bunch. You just got the thumbs up to make a piece of your app reactive so you build a subscription around a single component. If any data in that component’s query changes, your subscription will let you know with a single beautiful payload. Unfortunately, your marketing team then changes the layout of the page and you quickly learn that as your components change, your server must change. Updating 10 mutations and your subscriptions because a new<Footer/> got added is lame, so you hunt on for a better pattern.

Per-Entity Subscriptions

This type of subscription is the most popular. From PubSub textbooks to GraphQL example repos, you see it everywhere. It’s pretty simple: if you have a mutation that modifies a Team with ID of 123, then you publish a message to Team.123 in perfect Topic.Channel fashion. Simple, right? Well, until you call an AddTeam mutation. If you publish to channel Team.124, no one will be listening yet, so you’ll need to post it to parent channel, such as Team.userId. Another channel just for listening to teams you get added to? Not great, but not terrible.

Next, assume that you have a RemoveFromCompany mutation that removes you from every Team and for every team, removes each Todo item. The poor client listening on theTeam and Todo channels is going to receive 1 message for each Todo, Team, and company; That’s m*n + m + 1 messages! A client handling 100 updates in quick succession may drop below 60fps, but again, not terrible. A smart batching strategy can easily mitigate this.

Third, and here’s where it gets fun, is that per-entity subscriptions inherently overfetch. Imagine a ChangeTeamName mutation. You only change a single field, but the subscription returns the entire Team object because you share it with other mutations. Sure, some of those fields may be expensive to fetch, but what’s a little overfetching if it means the code is maintainable…

Unfortunately, maintainability is a nightmare, which brings us to the real reason why per-entity subscriptions are bad news: they transfer state, not the event. For example, if I pop a toast when a Todo gets removed, will RemoveFromCompany trigger 100 toasts? If a user got added to a team, are they brand new, or did they reactivate? Who added them? Was it you from your cellphone while you’re looking at the same page on your computer? At the end of the day, I came to the harsh realization that I needed more than just state. I needed the event.

Before realizing what the problem was, I solved this by bifurcating state and event into separate Team and Event channels. I told myself the Team channel would handle updates to the Team object, and the Event channel would handle any toasts or one-off messages. As the app grew and business logic changed, I realized the Event payload often contained the entire Team object and I didn’t even need the Team channel! In fact, the Event payload was almost identical to the payload of the mutation that triggered it, yet here I was working tirelessly to squash bugs to keep the 3 separate queries and handlers returning the same result. Like an idiot.

Per-mutation subscriptions

A subscription is a mutation you didn’t know you wanted. With that in mind, it makes perfect sense to subscribe to a mutation. Imagine a subscription payload that looks identical to the mutation payload, sharing a single handler and query fragment. When business logic changes, you go to the single source of truth to update.

Unfortunately, where per-query subscriptions fail because they require constant changes to the back-end, per-mutation subscriptions fail because they require constant changes to the front-end. Imagine if you had a single channel for each mutation, like ChangeTeamName.123. Any component that used the team.name field would also have to subscribe to it. Looks like another dead-end for maintainability.

Secondly, we’re still overfetching. The mutation payload likely provides more data than the component needs. For example, the RemoveFromCompany payload might include teams, but your TodoList component only cares about the Todos that got removed. Do you chose to overfetch, or do you write a second handler?

Hybrid Subscriptions

If you already have subscriptions, you probably implement some sort of hybrid without realizing it. Either you have separate ADD/REMOVE/UPDATE subscriptions and subscribe to all 3 simultaneously on the client, or your subscription payload is a union of an added item, removed item, or updated item, or you just fetch the whole item regardless of the type (which gets tricky for deleted items!). Regardless, both per-entity and per-mutation subscriptions lack maintainability and suffer from overfetching, but in opposing ways. Back in the days of fixed-payload subscriptions, we had to pick one. Thankfully, this is where GraphQL saves the day. We can take the best parts of per-entity and per-mutation strategies and create a new type of hybrid subscription that has never been possible, until now

… just kidding! Paywalls suck, Medium.

How to implement Hybrid Subscriptions

Let’s start with the server. In the ChangeTeamName mutation, we return a ChangeTeamNamePayload like team { name }. We know publishing to a channel like ChangeTeamName.123 will make life hard for the front-end developers, so instead, we publish to Team.123. Why use entity-based channels? Because it’s the perfect compromise. If we published everything to a single channel, then we’d be sending the user every message, even the ones don’t don’t affect her current view. She probably doesn’t care about an updated Todo item if she isn’t currently looking at her list of todos! Conversely, it’s cruel to make a <Team/> component rely on an ever-changing list of mutations that affect the team; but <Team/> and <TodoList/> components that both subscribe to a Team subscription? Yeah, that’s manageable.

Next, the only thing to add to your call to publish is the mutation name: publish(Team.123, {team, type: ChangeTeamNamePayload}). The subscription payload is simply a union that resolves the concrete type based on this type. It’s so easy it’s like the Betty Crocker of real-time apps.

Since mutations and subscriptions return the same object type, they can share handlers. Since subscriptions do the grouping on the server, components don’t have to. All that’s left to fix is the overfetching.

To solve this, I break each mutation query up into standalone fragments. For example,

fragment ChangeTeamNameMutation_team on ChangeTeamNamePayload {
team {
name
}
}
ChangeTeamNameMutation {
...ChangeTeamNameMutation_team
}
TeamSubscription {
...ChangeTeamNameMutation_team
...ChangeTeamColorMutation_team
}

As seen, in the subscription I include every fragment with a _team suffix. Not too hard, but a codemod could make it even easier (*hint hint*). Writing per-channel fragments and handlers makes great sense because 1 mutation calls many subscription channels, and 1 subscription is triggered by many mutations. Since GraphQL lets you fragment on type, this isn’t a problem. Even for mutations that can return widely varying payloads (ie a ToggleTeam mutation that either adds or removes) you can still employ unions and interfaces. Even better, it means if all your business logic lives in the same handler. Need to pop a toast to the mutator, quietly update the state on the mutator’s other devices, and announce a separate message to the rest of the team? No sweat. Here’s what it looks like in production.

Conclusion

Hope this inspires you to add some real-time functionality to your app! Thanks to GraphQL, I’m able to write subscriptions that use the same queries and handlers as my mutations, which means they’re maintainable, have drastically reduced overfetching, and best yet, the pattern scales modularly so you can make your app reactive 1 mutation at a time, which should make your boss happy. Working on something similar? Reach out!

More by Matthew Krick

Topics of interest

More Related Stories