GraphQL mutations and cache invalidation

Arnaud Bezançon
WorkflowGen
Published in
4 min readJul 27, 2017

A mutation in GraphQL doesn’t only change the data on the server-side but also returns a result. This result is equivalent to GraphQL queries with nested fields if object types are returned. When caching is implemented in the resolvers, you might have to deal with cache invalidation for your mutations in order to prevent inconsistent results.

This article looks at the potential issues and some solutions to manage cache invalidation within your mutations when the cache is managed per request.

I’ll use CodeSandbox.io for my live examples, so you can just play the queries or change the GraphQL server code directly to do your own experiments.

Important: I recommend either duplicating the code (via the Fork menu) for live testing, or downloading the sample.

Implementing DataLoaders

The first step is to add a cache management for the authors with the excellent facebook/dataloader library.

In this example, the DataLoader instance is created per request, which means that the cache is not shared among all API users.

export function context(headers, secrets) { 
return {
headers,
secrets,
loaders : {
authors: new DataLoader(
ids => getAuthors(ids),
{ cache: true, batch: true })
}
}
}

You can test different values for cache and batch boolean options and check the logs to see how the authors are loaded according to the combination you’ve defined.

Here, the author-related resolvers now use the cache loader:

Query: {
posts: () => posts,
author: (_, { id }, context) => context.loaders.authors.load(id),
},
...Post: {
author: (post, args, context) => context.loaders.authors.load(post.authorId),
},

Add a mutation to update an author

The updateAuthor mutation is designed to manage the partial updates described in this article.

When you launch a single mutation operation, everything is fine, and
the author information returned by the updateAuthor mutation is up-to-date.

Side-effects could appear when you have multiple mutation operations.

In this example, two mutations update the same author and two mutations update a post related to this author:

mutation {
updateAuthor1a: updateAuthor(authorId: 1, firstName: "Leonard", lastName: "Da Vinci") {
id
firstName
lastName
}
upvote1:upvotePost(postId: 1) {
id
title
votes
author {
id
firstName
lastName
}
}
updateAuthor1b: updateAuthor(authorId: 1, firstName: "Aldous", lastName: "Huxley") {
id
firstName
lastName
}
upvote2:upvotePost(postId: 1) {
id
title
votes
author {
id
firstName
lastName
}
}

Result:

{
"data": {
"updateAuthor1a": {
"id": 1,
"firstName": "Leonard",
"lastName": "Da Vinci"
},
"upvote1": {
"id": 1,
"title": "Introduction to GraphQL",
"votes": 3,
"author": {
"id": 1,
"firstName": "Leonard",
"lastName": "Da Vinci"
}
},
"updateAuthor1b": {
"id": 1,
"firstName": "Aldous",
"lastName": "Huxley"
},
"upvote2": {
"id": 1,
"title": "Introduction to GraphQL",
"votes": 4,
"author": {
"id": 1,
"firstName": "Leonard",
"lastName": "Da Vinci"
}
}
}
}

In this example, the upvote2 operation result returns “Da Vinci” instead of “Huxley”.

Note: Mutations are executed in sequential order, which means that if the DataLoader has its batch mode enabled, a batch is created per mutation.

Deactivate caching for mutations

If the GraphQL request is a mutation, you can deactivate the cache as a whole. This drastic solution ensures that all the mutations executed within a request will return only fresh data from the server-side.

The DataLoader constructor accepts a cache option that can be used to deactivate caching. It means that you have to know if the GraphQL request is a mutation before the resolvers are called and the dataloaders are created. I haven’t yet found an efficient solution to retrieve this information without parsing the HTTP request or loading the GraphQL query to browse the AST. But a simple implementation of this solution is to disable the cache for all HTTP POST requests (if you don’t support GET for mutations).

Clear the cache entries in the mutation resolvers

The dataloader library offers a simple solution to remove an entry from the cache using the clear(key) function.

One solution is to clear all of the existing entries in all of the dataloaders in your mutation resolvers. This solution prevents inconsistencies in the mutation results while still relying on the cache in the result query.

An optimized solution, as implemented in this sample, is to clear only the altered entries:

updateAuthor: (_, {authorId, firstName, lastName}, context) => { 
const author = find(authors, { id: authorId });
if (!author) {
throw new Error(`Couldn't find author with id ${authorId}`);
}
if (firstName !== undefined) {
author.firstName = firstName;
}
if (lastName !== undefined) {
author.lastName = lastName;
}
context.loaders.authors.clear(authorId);
return author;
},

But this solution can be more complex to implement when you have multiple dataloaders for the same data (e.g. AuthorsByUsername), or when a mutation has some side-effects on other server-side objects.

Conclusion

For many GraphQL developers, cache invalidation in mutations can be a non-issue because the cache is managed per request and only one mutation is done per request.

If you want to avoid any side-effects in your mutation results when the cache is managed per request, you can simply deactivate the cache for HTTP POST requests.

Cache invalidation management when the cache is shared among multiple requests/users is another story though, with a higher level of complexity and many possible server-side mechanisms to ensure up-to-date data.

--

--

Arnaud Bezançon
WorkflowGen

Full Stack Architect, Creator of WorkflowGen, Advantys co-founder and CTO.