Tips and Tricks for working with Apollo Cache

Tommy Suwunrut
RBI Tech
Published in
9 min readOct 29, 2020
Photo by Stephen Walker on Unsplash

With its declarative fetching, helpful tooling, extensive type definitions, and built-in integration with React, Apollo Client has played a fundamental role in simplifying frontend architecture over the past few years. Its built-in caching layer, which allows you to retrieve previously requested data without needing to make additional network requests to the server, has the potential to make any application feel snappier. Apollo even provides ways of warming up the cache to be used for later, freeing our frontends from the dreaded loading spinners.

But cache invalidation is one of those notoriously difficult problems in any computer program, and trying to update or bust Apollo’s cache after server-side updates is far from a perfect experience, especially when the queries being cached have many filters and constraints. Let’s begin with an overview of how Apollo’s cache works, and then discuss the tradeoffs involved with a few different approaches to working with it.

Throughout this article we’ll be using this Mock List application as a reference.

Cache Keys

When you make any graphQL query, by default Apollo caches the response in what it calls a flat, normalized lookup table. It constructs a unique identifier for each object returned from your query, by combining its id or _id properties with the __typename defined in your schema.

So if the application above executes queries as the user types “G”, then “Go”, then “Go g”, all the way to “Go groceries shopping”, Apollo could potentially pull down information about many different tasks and cache each one roughly like this:

Task:1234: { name: "Go grocery shopping" }
Task:2345: { name: "Go to gym" }
Task:3456: { name: "Learn GoLang" }

This is great if the user navigates to a detail view about a given task, because there will already be some data immediately available.

Apollo will also store the results of each of those individual queries, in case you make the same exact query again later. That data is stored in Apollo’s cache, based on the name of the query you execute, as well as any variables you pass to it, like this:

ROOT_QUERY:
tasks(name: "G"): [{}, {}, {}]
tasks(name: "Go"): [{}, {}, {}]
tasks(name: "Gol"): [{}]

If a user adds a new task or updates an existing task, then Apollo will update the corresponding single cache key for that individual task, but unless you tell it exactly what you want it to do, it won’t know which collection queries needs to be updated or invalidated. There are several ways to handle this.

Fetch Policies

The easiest workaround by far is to customize the fetchPolicy for your collection queries. The following are the list of fetch policies that you can specify for your query:

  1. cache-first
  2. cache-and-network
  3. network-only
  4. no-cache
  5. cache-only

For the purpose of this article, we’ll only be discussing the first three as they are more likely to be used.

cache-first

This is the default. Apollo will first look in the cache to see if there is a query matching the given name and filters, and if so, it will retrieve it; otherwise, it will make a network request. The drawback to this method lies with its inability to recognize server-side changes to the data. As data changes on the backend, you will have to manually modify the cache using a different Apollo method to have the query reflect any updates.

cache-and-network

This policy behaves the same as cache-first, but even if Apollo does locate an existing response in the cache, it will still make a network request in the background and update the cache (and in turn, the UI) if the response differs from what it has stored. This provides the user with some information right away, and potentially more accurate information after the query returns.

For many simple applications this will be enough. However for applications that require 100% accuracy, or for larger, more complex applications with very expensive queries, this may not be a suitable solution as Apollo will always be making a query in the background and that can cause performance issues for the frontend.

network-only

With this policy, Apollo will bypass the cache and always make a network request. However it will still store the result of the request in the cache in case the same query with a different fetch policy is being made elsewhere in the application. This is the easiest solution to making sure your app will always have the most up-to-date data. The problem is we’ve now bypassed the caching feature and will lose out on that snappy feeling and will end up having to show loading wheels more often through out the app. Depending on your client’s needs, this may not be acceptable.

Refetch Queries

Consider the following query hook:

useQuery(FETCH_TASKS, { variables: { name: input } })

This query retrieves a collection of task records and accepts a name variable to filter the list down. Each time the name filter is updated, Apollo will store the server response for that name in its cache individually.

When new tasks are added to the data store, we can tell Apollo to refetch any previous FETCH_TASKS query results in its cache like this:

useMutation(
CREATE_TASK,
{
refetchQueries: [getOperationName(FETCH_TASKS)]
}
)

This way, if a user tends to search for particular data again and again, they can be confident they are receiving the most current results. However, this particular approach will only work if the component that made the originalFETCH_TASKS query is still mounted. Also, any queries with the same operation name will be refetched simultaneously. So you have over 100+ queries with the same operation name in the cache, they will all be refetched at once, which can be easily affect the performance of your application

It’s also possible to refresh a more targeted subset of queries, like this:

useMutation(
CREATE_TASK,
{
refetchQueries: [
{ query: FETCH_TASKS, { variables: { name: 'shopping' } } }
]
}
)

But this approach can become problematic when there are additional filters that get passed to the query as variables, and you have to decide which permutations will require refetching — or when there are pagination considerations with cascading off-by-one errors when a new record gets inserted in the middle of a list. Refetches are still done simultaneously, which can have performance implications.

Because of these difficulties, Apollo provides multiple ways to update the cache manually, depending on the needs to your application.

Writing to the Cache

Apollo provides us a way to latch onto the success of a mutation, so we can bypass refetching and write directly to the cache and update specific query results.

This approach eliminates unnecessary network traffic, because knowing the mutation’s result often enables you to decide how the cache (and UI) should be updated accordingly.

useMutation(CREATE_TASK, {
update: (cache, mutationResult) => {
const newTask = mutationResult.data.createTask;
const data = cache.readQuery({
query: FETCH_TASKS, variables: { name: newTask.name }
});
cache.writeQuery({
query: FETCH_TASKS,
variables: { name: newTask.name },
data: { tasks: [...data.tasks, newTask]
})
}
})

The update callback is triggered once the mutation has finished. The first argument supplied will be the Apollo cache, and the second will be the mutation result object.

The cache is capable of reading the result of any existing query in the store via readQuery and then we can update the cache with new data from out mutation result using writeQuery.

If we know we want the new task to be appended to the end of a list, this code will accomplish that task for theFETCH_TASKS query for an exact match on that new task’s name. Unfortunately, if results are sorted in a particular way or paginated server-side, there is no convenient way to find where this new record fits without duplicating a fair bit of logic.

Also, if we want to update the cache for partial matches, we would have to iterate through each potential match (“G”, “Go”, Gol”, and so on). Complicating matters further, it’s important to note that readQuery throws an error if a given query isn’t in the cache yet, so a robust implementation with different combinations of variables would need to be wrapped in a fair bit of conditional logic or try / catching.

Optimistic UI

For fairly simple cases, when writing directly to the cache is feasible, and the result of a given mutation is predictable, Apollo gives us a way to update the UI even before the network request returns a response! This can be a game changer for users who have slow internet connections.

When the server does ultimately respond, the optimistic result will be replaced by the actual result. If the mutation fails, the optimistic result will be discarded.

useMutation(CREATE_TASK, {
update: (cache, mutationResult) => {
const newTask = mutationResult.data.createTask;
const data = cache.readQuery({
query: FETCH_TASKS, variables: { name: newTask.name }
});
cache.writeQuery({
query: FETCH_TASKS,
variables: { name: newTask.name },
data: { tasks: [...data.tasks, newTask]
})
},
optimisticResponse: {
__typename: "Mutation",
createTask: {
__typename: "Task",
_id: `This part we don't know yet but it will be a unique string so just to be safe ${uuid()}`,
name: input.name,
description: input.description,
}
}
})

It’s important that the optimisticResponse includes the expected __typename property, both for the mutation itself and for any data the mutation returns. In this case, it’s possible to infer almost all the information the server will return from the user’s input, with the exception of its _id, because this is assigned on the server. This is fine so long as we are careful to assign a unique string that can be replaced once the server does assign a valid id.

In additional to the other issues detailed with writeQuery, these are two other problems that I’ve experienced using this method:

1. You may not always know the result of the mutation, therefore an optimistic result cannot be inferred. If the data that the frontend works with gets massaged by several middleware layers before getting persisted, it may look entirely different in ways that are impossible to predict.

2. A successful mutation may not mean that the data was successfully persisted. If your data goes through multiple middleware orchestration layers, and the server may send a successful response back after getting past the first workflow, and do additional processing in the background. In these cases, the frontend won’t know if insertion failed at a later point. This may be a good case for subscriptions, but that’s a topic for another post.

Busting the Cache

The last method we’ll be discussing is the one that’s worked the best for me personally. In addition to being able to read / write to the Apollo cache, if you are on Apollo Client 3.0 or higher, you can also easily evict specific sets of cache keys when a mutation succeeds.

Following this strategy, Apollo will be forced to refetch certain queries if the user re-requests them, but won’t trigger potentially hundreds of requests just because a user might re-request them later. And this allows us to leave the default fetchPolicy in place so that we can benefit from caching when the user is not adding data via mutations.

useMutation(CREATE_TASK, {
update: cache => {
cache.evict({
id: "ROOT_QUERY",
field: "tasks"
})
}
}

This code assumes we want to flush out any responses to any queries that are cached via the tasks key but we can also be more targeted if we like.

useMutation(CREATE_TASK, {
update: (cache, mutationResult) => {
const newTask = mutationResult.data.createTask;
cache.evict({
id: "ROOT_QUERY",
field: "tasks",
args: { name: newTask.name }
})
}
})

Conclusion

To wrap things up, none of these solutions are “perfect”. No powerful tool is ever perfect. But the Apollo team has given us a wide menu of options to pick from to match against our intended use case. You will likely need to play around with a few of them to determine which one best suits your application, but I am confident you will be able to fashion a workable solution to your problem out of the tools provided in the latest Apollo release.

The Apollo Data Graph Platform or “Apollo Client” software is the property of and a registered trademark of Apollo Graph Inc.

--

--