Understanding GraphQL Caching Mechanisms

Oleksandr Ovcharov
Outreach Prague
Published in
13 min readSep 24, 2024

In this blog post, I’ll demystify GraphQL caching by exploring both server-side and client-side strategies using Apollo Client. We’ll delve into how data is stored and normalized, discuss various caching strategies, and dive into advanced cache configurations like custom cache IDs, field policies, cache redirects, and handling non-normalized objects. By the end, you’ll have a comprehensive understanding of how to implement and fine-tune caching in your GraphQL applications to enhance performance and user experience.

I started working with GraphQL a few years ago, and its caching mechanism has always been somewhat mysterious to me. When I started working in Outreach, one of the features I had to implement was custom pagination caching logic. Due to my lack of experience with Apollo’s caching, my initial solution was not perfect. So today, I decided to take a deep dive into GraphQL caching, understand it thoroughly, and document my findings in this blogpost.

Apollo Client stores the results of your GraphQL queries in a local, normalized, in-memory cache. This enables Apollo Client to respond almost immediately to queries for already-cached data, without even sending a network request. [1]

Caching reduces the load on your server, speeds up the user experience by reducing latency, and helps in handling large-scale applications more efficiently. In essence, caching is about balancing the freshness of data with performance.

Real-World Applications of GraphQL Caching

In real-world applications, effective use of GraphQL caching can drastically improve the performance and scalability of your app. For instance, in an e-commerce platform, frequently accessed data such as product listings or user carts can be cached, reducing the need for repeated requests to the server. This not only speeds up the user’s experience by loading data faster but also reduces the server load, allowing the system to handle more users simultaneously. Another example could be in a social media app, where user profiles and feed data can be cached to provide a seamless experience as users navigate through different sections of the app.

How is data stored?

In contemporary applications, data management often involves storing information in a way that optimizes both retrieval and updating processes. A common strategy is to maintain a flat structure — a lookup table of entities that reference one another. These entities represent the core objects your application works with, and a single entity might aggregate data from multiple sources or requests, especially when different requests fetch varying attributes of the same object.

However, data received from external sources like APIs is frequently nested, containing multiple layers of related information. For instance, consider the following JSON response from a web service:

{
"customer": {
"type": "Individual",
"id": "cust_001",
"name": "Alice Johnson",
"orders": [
{
"order_id": "order_123",
"product": "Laptop",
"shipping": {
"address": "456 Elm Street",
"city": "Somewhere",
"postal_code": "78910"
}
}
]
}
}

In this example, the customer object contains an array of orders, each with its own nested shipping details. To efficiently store this multi-layered data within a flat structure, the system employs a process known as data normalization.

Data normalization

Whenever the Apollo Client cache receives query response data, it does the following:

When the system receives such nested data, it transforms it through the following steps:

  1. Identify Distinct Entities: It parses the data to find all unique entities, such as customers, orders, and shipping details.
  2. Generate Unique Identifiers: For each entity, the system creates a unique ID, often by combining key attributes like type and ID fields.
  3. Replace Nested Structures with References: It replaces nested entities with references (pointers) to their normalized counterparts in the lookup table.
  4. Store Normalized Entities: The entities are stored individually in the flat structure. If an entity with the same unique ID already exists, the system merges their fields to update the stored data.

This normalization is crucial because it builds an efficient and scalable representation of your data within the system. By flattening the data and using references, the application can quickly access and update information as the state changes, without the overhead of traversing complex nested structures.

Caching strategies

There are several strategies for caching in GraphQL, including server-side caching and client-side caching. Each has its own use cases and benefits, depending on the requirements of your application. Let’s focus on how to implement them effectively.

Server-side caching

Server-side caching involves storing query results on the server to reduce the need to repeatedly process the same request. This can significantly reduce server load and improve response times for users.

One common approach is using Automatic Persisted Queries (APQ), where query hashes are stored and reused, reducing the need for clients to send the entire query string. Coupled with caching mechanisms like Redis, APQ can provide robust and scalable caching solutions.

Another approach is using Apollo Directives like @cacheControl, which allows you to specify cache policies directly within your schema. However, while convenient, this method has its limitations, such as limited flexibility, complexity in large schemas, and cache invalidation challenges.

For more granular control, you can manually set the cache-control header in your server’s response. This approach allows for precise control over caching policies, making it suitable for scenarios where fine-tuned caching strategies are required.

Apollo Directives

One way to implement server-side caching is by using the @cacheControl directive from Apollo Server. This directive allows you to define a max-age and scope for a type or resolver. You can learn more about how these directives calculate the cache-control header in the Apollo documentation.

While the @cacheControl directive offers a convenient way to implement caching, it comes with a few downsides:

  1. Limited Flexibility:
    The @cacheControl directive provides basic caching controls like max-age and scope, but it may not offer the level of granularity required for more complex caching strategies. For example, if you need to cache data based on custom logic or conditions, the directive might not be sufficient.
  2. Complexity in Large Schemas:
    In a large schema with many types and resolvers, managing cache settings via directives can become cumbersome. Applying and maintaining @cacheControl across a large number of resolvers can lead to increased complexity and potential inconsistencies.
  3. Cache Invalidation Challenges:
    Since the @cacheControl directive is tied to the schema, invalidating or updating cached data in response to specific events (e.g., data mutations or external changes) can be challenging. This often requires additional logic outside of the directive to manage cache invalidation effectively.
  4. Overhead:
    Although convenient, the @cacheControl directive adds a layer of abstraction that might introduce overhead in scenarios where highly optimized caching strategies are required. In such cases, manually setting cache headers might be more efficient.

Set the response header ‘cache-control’ manually.

Another approach is to set the cache-control header manually. This method gives you full control over how caching is handled and allows for more fine-tuned caching strategies. Manually setting the cache-control header allows you to tailor caching strategies based on specific conditions or user interactions, offering more flexibility than automated solutions.

import { ApolloServer } from 'apollo-server';
import { ApolloServerPluginCacheControl } from 'apollo-server-core';

const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [ApolloServerPluginCacheControl({ defaultMaxAge: 5 })],
});

Client-side caching

Client-side caching with Apollo is primarily handled through the InMemoryCache. This cache can be configured with type policies that dictate how different types of data should be cached and merged. For example, when dealing with paginated data, custom merge functions can be implemented to ensure that incoming data is merged correctly with existing cached data.

// In this example, the InMemoryCache is configured with a 
// custom typePolicy to handle the caching of paginated data efficiently.

import { InMemoryCache } from '@apollo/client';

const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
books: {
keyArgs: false,
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
}

Just like server-side caching, client-side caching strategies come with their own set of advantages and challenges.

Default InMemoryCache

Pros:

  • Automatically normalizes and caches data based on id or _id fields.
  • Simplifies cache management for straightforward use cases.

Cons:

  • May not handle complex data structures or relationships without additional configuration.
  • Limited when dealing with non-standard identifiers or deeply nested data.

Custom Type Policies and Merge Functions

Pros:

  • Provides fine-grained control over how data is cached and merged.
  • Essential for handling pagination, infinite scrolling, and non-standard data models.

Cons:

  • Increases complexity of cache configuration.
  • Higher risk of cache inconsistencies if merge functions are not implemented correctly.

Cache Redirects

Pros:

  • Optimizes performance by reusing existing cached data.
  • Reduces unnecessary network requests.

Cons:

  • Requires careful setup to ensure correct data is retrieved.
  • Can become complex when dealing with multiple data variations.

Persistent Cache with apollo-cache-persist

Pros:

  • Enables offline capabilities by persisting data across sessions.
  • Improves user experience in unstable network conditions.

Cons:

  • Increases storage requirements on the client side.
  • May introduce data synchronization challenges when coming back online.

Advanced Cache Configuration

Apollo’s InMemoryCache offers a variety of configuration options that allow you to fine-tune how data is cached and retrieved. For instance, you can use field policies to customize how specific fields are cached. This includes defining custom merge functions, specifying key arguments, and handling special cases like pagination.

Additionally, you can leverage cache redirects to direct queries to existing cached data, even if the query’s structure or parameters differ slightly. This can be particularly useful in scenarios where multiple queries might request overlapping data.

Customizing Cache IDs

Scenario: A multi-vendor e-commerce platform where products are identified by unique SKUs rather than a typical id field.

Problem: The platform’s Product type does not use a traditional id field but instead uses a SKU (Stock Keeping Unit) for identification. Since Apollo Client by default expects an id or _id field for normalization, this can lead to issues with how products are stored and retrieved from the cache.

Solution: You can configure custom cache IDs by using the keyFields option in the typePolicies. This tells Apollo Client to use the SKU as the unique identifier:

// This configuration ensures that the cache uniquely identifies 
// Product objects based on their UPC field.

const cache = new InMemoryCache({
typePolicies: {
Product: {
keyFields: ["upc"],
},
},
});

Why Use This? This configuration is critical when your data model doesn’t follow conventional ID practices. It ensures that products are correctly identified and cached, reducing redundant network requests and improving performance.

Field Policies for Custom Reading and Merging

Scenario: A social media app that supports infinite scrolling for user comments.

Problem: When implementing infinite scrolling, each new set of comments fetched from the server should be appended to the existing list in the cache. However, by default, Apollo Client replaces the entire list each time new data is fetched, causing previous comments to disappear.

Solution: Implement a custom merge function in a field policy to concatenate the existing and incoming arrays:

// This merge function concatenates the existing and incoming comments, 
// preserving previously cached data.

const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
comments: {
keyArgs: false,
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
});

Why Use This? This approach is essential for handling pagination, especially when you need to preserve already fetched data and continuously append new data without losing the previous content.

Cache Redirects

Scenario: A blog platform where a list of posts and individual post details are queried separately.

Problem: After querying a list of posts, querying the details of one of those posts results in a redundant network request, even though the data is already present in the list.

Solution: Implement a cache redirect to pull data from the cached list when querying for an individual post:

// This configuration ensures that when querying a single post by ID, 
// Apollo Client will first look for that book in the cached list of
// books before making a network request.

const cache = new InMemoryCache({
cacheRedirects: {
Query: {
post: (_, args, { getCacheKey }) =>
getCacheKey({ __typename: ‘Post’, id: args.id }),
},
},
});

Why Use This? Cache redirects are ideal when you want to optimize your app by avoiding unnecessary network requests for data that is already available in the cache, thus improving load times and reducing server load.

Handling Non-Normalized Objects

Scenario: A content management system (CMS) that manages books and their authors, where Author objects do not have unique identifiers.

Problem: The Author type lacks an id field, which makes it challenging for Apollo Client to recognize when two different Author objects represent the same person across different Book queries.

Solution: Use a custom merge function to handle these nested, non-normalized objects:

// This setup ensures that when different queries fetch parts 
// of the same Author object (even if it's nested within Book),
// the cache correctly merges these parts into a coherent whole.

const cache = new InMemoryCache({
typePolicies: {
Book: {
fields: {
author: {
merge(existing, incoming, { mergeObjects }) {
return mergeObjects(existing, incoming);
},
},
},
},
},
});

Why Use This? This technique is useful when your schema includes types that aren’t easily normalizable. It allows the cache to intelligently merge data from multiple queries, ensuring consistency in the UI.

The code assumes that the mergeObjects function will correctly handle merging properties of the same Author object. This works in scenarios where the Author object is indeed the same across multiple queries but lacks an ID field. The mergeObjects function combines the fields of the existing and incoming author objects, which can be useful when different queries return partial data about the same author.

However, this method assumes that all Author objects within a Book are the same across all instances in the cache, which may not be true. Without a unique identifier, there’s no way to ensure that two different Author objects won’t be incorrectly merged into one, potentially leading to data corruption or inaccurate cache entries.

Potential Pitfall: Merging Different Authors

In a real-world scenario, if two different books have different authors but the authors have the same name or some overlapping fields, using this method could mistakenly merge these distinct authors into a single object. For example:

  • Book A has an author “John Smith” born in 1980.
  • Book B has an author “John Smith” born in 1990.

Without a unique identifier, both authors could be merged into one, leading to a cache entry with incorrect or blended data.

Improving the Strategy

To avoid this problem, you should ensure that each Author object has a unique identifier. If your schema doesn’t provide a unique ID for Author, you might need to create a composite key based on multiple fields that uniquely identify an author. For example:

// By using a composite key, you can differentiate between 
// different authors with the same name but different birthdates.

const cache = new InMemoryCache({
typePolicies: {
Author: {
keyFields: ["name", "birthDate"], // Composite key using multiple fields
},
},
});

Custom Data Storage with Persistent Cache

Scenario: A mobile app that needs to support offline capabilities by persisting the cache to local storage.

Problem: The app must retain data across sessions and even when the app is restarted, ensuring that users have access to previously fetched data even without an internet connection.

Solution: Use apollo-cache-persist to persist the InMemoryCache to localStorage or IndexedDB:

import { InMemoryCache } from '@apollo/client';
import { persistCache } from 'apollo-cache-persist';

const cache = new InMemoryCache();

persistCache({
cache,
storage: window.localStorage, // Or IndexedDB for larger datasets
});

const client = new ApolloClient({
cache,
// other configurations
});

Why Use This? Persisting the cache is crucial for offline-first applications, ensuring that users have a seamless experience even when they’re not connected to the internet. It also helps reduce data fetching by reusing previously cached data across sessions.

Proper Utilization of keyArgs

Scenario: You have a query that fetches an Author along with their books. The books data is paginated, meaning it is fetched in chunks. However, due to the nature of caching, sometimes the Author data is present in the cache, but the books data might not be. You want to ensure that your application only returns the Author data if the associated books data is also available in the cache.

Problem: Without careful cache management, there’s a risk that the UI could render incomplete data — displaying the Author without their books, which could lead to a confusing or inconsistent user experience. This problem arises when the Author data is fetched and cached, but the books are paginated and not fully retrieved yet.

Solution: To solve this, you can use Apollo Client’s typePolicies and keyArgs features in combination with a custom read function. The keyArgs allows you to control how different variations of a field (e.g., different pages of books) are stored separately in the cache. The custom read function can then ensure that the Author is only returned when the books data is also available.

// keyArgs helps us distinguish between different pages of books

const cache = new InMemoryCache({
typePolicies: {
Author: {
fields: {
books: {
keyArgs: ["first", "after"],
},
},
},
},
});

Why Use This? This approach ensures that your application only displays complete data sets, avoiding scenarios where an Author might be displayed without their associated books. By leveraging keyArgs, you also ensure that each page of books is cached correctly, avoiding overwrites or loss of data. This strategy is particularly useful in scenarios where partial data can lead to a poor user experience, such as e-commerce websites, social media feeds, or any application where data completeness is critical for user interaction.

Conclusion

After thoroughly exploring the complexities of Apollo Client’s caching system, it’s evident that getting this right is essential for building scalable and responsive applications. Caching goes beyond simply storing data; it’s about delivering the correct information at the precise moment, without overtaxing your servers or keeping users waiting.

Achieving effective caching requires a thoughtful approach. Whether you’re ensuring that paginated content is fully loaded before display, or carefully merging data from multiple queries to avoid inconsistencies, the details make a significant difference. The scenarios we’ve discussed, from custom cache identifiers to handling data that doesn’t fit the usual patterns, highlight the substantial control you have to fine-tune your app’s performance using Apollo’s cache settings.

In the end, it’s not just about optimizing performance metrics; it’s about providing a smooth and reliable experience for your users. When caching is handled correctly, your application feels faster, more responsive, and more aligned with user expectations. This is the real reward — creating something that not only functions well but also genuinely satisfies the people who use it.

As you continue working with GraphQL and Apollo Client, I encourage you to experiment with these caching strategies. They might seem complex at first, but once you witness the improvements they bring to the user experience, you’ll recognize their true value in your development toolkit.

--

--