Normalized cache


Apollo Android provides two different kinds of caches: an HTTP cache and a normalized cache. The HTTP cache is easier to set up but also has more limitations. This page focuses on the normalized cache. If you're looking for a simpler albeit coarser cache, take a look at the HTTP cache.

Data Normalization:

The normalized cache stores objects by ID.

graphl
1query BookWithAuthorName {
2  favoriteBook {
3    id
4    title
5    author {
6      id
7      name
8    }
9  }
10}
11
12query AuthorById($id: String!) {
13  author(id: $id) {
14      id
15      name
16    }
17  }
18}

In the above example, requesting the author of your favorite book with the AuthorById query will return a result from the cache if you requested your favorite book before. This works because the author is stored only once in the cache and all the fields where retrieved in the initial BookWithAuthorName query. If you were to request more fields, like birthdate for an example, that wouldn't work anymore.

To learn more about the process of normalization, check this blog post

Storing your data in memory

Apollo Android comes with an LruNormalizedCache that will store your data in memory:

Kotlin
Java
Kotlin
1// Create a 10MB NormalizedCacheFactory
2val cacheFactory = LruNormalizedCacheFactory(EvictionPolicy.builder().maxSizeBytes(10 * 1024 * 1024).build())
3
4// Build the ApolloClient
5val apolloClient = ApolloClient.builder()
6  .serverUrl("https://...")
7  .normalizedCache(cacheFactory)
8  .build())

Persisting your data in a SQLite database

If the amount of data you store becomes too big to fit in memory or if you want your data to persist between app restarts, you can also use a SqlNormalizedCacheFactory. A SqlNormalizedCacheFactory will store your data in a SQLDelight database and is defined in a separate dependency:

Kotlin
build.gradle.kts
1dependencies {
2  implementation("com.apollographql.apollo:apollo-normalized-cache-sqlite:x.y.z")
3}

Note: The apollo-normalized-cache-sqlite dependency has Kotlin multiplatform support and has multiple variants (-jvm, -android, -ios-arm64,...). If you are targetting Android and using custom buildTypes, you will need to help Gradle resolve the correct artifact by defining matchingFallbacks:

Kotlin
Groovy
build.gradle
1android {
2  buildTypes {
3    create("custom") {
4      // your code...
5      matchingFallbacks = listOf("debug")
6    }
7  }
8}

Once the dependency is added, create the SqlNormalizedCacheFactory:

Kotlin
Java
1// Android
2val sqlNormalizedCacheFactory = SqlNormalizedCacheFactory(context, "apollo.db")
3// JVM
4val sqlNormalizedCacheFactory = SqlNormalizedCacheFactory("jdbc:sqlite:apollo.db")
5// iOS
6val sqlNormalizedCacheFactory = SqlNormalizedCacheFactory("apollo.db")
7
8// Build the ApolloClient
9val apolloClient = ApolloClient.builder()
10  .serverUrl("https://...")
11  .normalizedCache(sqlNormalizedCacheFactory)
12  .build())

Chaining caches

To get the best of both caches, you can chain an LruNormalizedCacheFactory with a SqlNormalizedCacheFactory:

Kotlin
Java
Kotlin
1
2val sqlCacheFactory = SqlNormalizedCacheFactory(context, "db_name")
3val memoryFirstThenSqlCacheFactory = LruNormalizedCacheFactory(
4    EvictionPolicy.builder().maxSizeBytes(10 * 1024 * 1024).build()
5).chain(sqlCacheFactory)
6

Reads will read from the first cache hit in the chain. Writes will propagate down the entire chain.

Specifying your object IDs

By default, Apollo Android uses the field path as key to store data. Back to the original example:

graphl
1query BookWithAuthorName {
2  favoriteBook {
3    id
4    title
5    author {
6      id
7      name
8    }
9  }
10}
11
12query AuthorById($id: String!) {
13  author(id: $id) {
14      id
15      name
16    }
17  }
18}

This will store the following records:

  • "favoriteBook": {"id": "book1", "title": "Les guerriers du silence", "author": "ApolloCacheReference{favoriteBook.author}"}

  • "favoriteBook.author": {"id": "author1", name": "Pierre Bordage"}

  • "author("id": "author1")": {"id": "author1", "name": "Pierre Bordage"}

  • "QUERY_ROOT": {"favoriteBook": "ApolloCacheReference{favoriteBook}", "author(\"id\": \"author1\")": "ApolloCacheReference{author(\"id\": \"author1\")}"}

This is undesirable, both because it takes more space, and because modifying one of those objects will not notify the watchers of the other. What you want instead is this:

  • "book1": {"id": "book1", "title": "Les guerriers du silence", "author": "ApolloCacheReference{author1}"}

  • "author1": {"id": "author1", name": "Pierre Bordage"}

  • "QUERY_ROOT": {"favoriteBook": "book1", "author(\"id\": \"author1\")": "author1"}

To do this, specify a CacheKeyResolver when configuring your NormalizedCacheFactory:

Kotlin
Java
Kotlin
1val resolver: CacheKeyResolver = object : CacheKeyResolver() {
2  override fun fromFieldRecordSet(field: ResponseField, recordSet: Map<String, Any>): CacheKey {
3    // Retrieve the id from the object itself
4    return CacheKey.from(recordSet["id"] as String)
5  }
6
7  override fun fromFieldArguments(field: ResponseField, variables: Operation.Variables): CacheKey {
8    // Retrieve the id from the field arguments.
9    // In the example, this allows to know that `author(id: "author1")` will retrieve `author1`
10    // That sounds straightforward but without this, the cache would have no way of finding the id before executing the request on the
11    // network which is what we want to avoid
12    return CacheKey.from(field.resolveArgument("id", variables) as String)
13  }
14}
15
16val apolloClient = ApolloClient.builder()
17    .serverUrl("https://...")
18    .normalizedCache(cacheFactory, resolver)
19    .build()

For this resolver to work, every object in your graph needs to have a globally unique ID. If some of them don't have one, you can fall back to using the path as cache key by returning CacheKey.NO_KEY.

Using the cache with your queries

You control how the cache is used with ResponseFetchers:

Kotlin
Java
1// Get a response from the cache if possible. Else, get a response from the network
2// This is the default behavior
3val apolloCall = apolloClient().query(BookWithAuthorName()).responseFetcher(ApolloResponseFetchers.CACHE_FIRST)

Other possibilities are CACHE_ONLY, NETWORK_ONLY, CACHE_AND_NETWORK_ONLY and NETWORK_FIRST. See to the ResponseFetchers class for more details.

Reacting to changes in the cache

One big advantage of using a normalized cache is that your UI can now react to changes in your cache data. If you want to be notified every time something changes in book1, you can use a QueryWatcher:

Kotlin
Java
1  apolloClient.query(BookWithAuthorName()).watcher().toFlow().collect { response ->
2      // This will be called every time the book or author changes
3  }

Interacting with the cache

To manipulate the cache directly, ApolloStore exposes read() and write() methods:

Kotlin
1  // Reading data from the store
2  val data = apolloClient.apolloStore.read(BookWithAuthorName()).execute()
3
4  // Create data to write
5  val data = BookWithAuthorName.Data(
6    id = "book1",
7    title = "Les guerriers du silence",
8    author = BookWithAuthorName.Author(
9      id = "author1",
10      name = "Pierre Bordage"
11    )
12  )
13  // Write to the store. All watchers will be notified
14  apolloClient.apolloStore.writeAndPublish(BookWithAuthorName(), data).execute()

Troubleshooting

If you are experiencing cache misses, check your cache size and eviction policy. Some records might have been removed from the cache. Increasing the cache size and/or retention period will help hitting your cache more consistently.

If you are still experiencing cache misses, you can dump the contents of the cache:

Kotlin
Java
1val dump = apolloClient.getApolloStore().normalizedCache().dump();
2NormalizedCache.prettifyDump(dump)

Make sure that no data is duplicated in the dump. If it is the case, it probably means that some objects have a wrong CacheKey. Make sure to provide a CacheKeyResolver that can work with your graph. All objects should have a unique and stable ID. That means that the ID should be the same no matter what path the object is in the graph. That also mean you have to include the identifier field in your queries to be able to use in from the CacheKeyResolver.

Finally, make sure to design your queries so that you can reuse fields. A single missing field in the cache for a query will trigger a network fetch. Sometimes it might be useful to query an extra field early on so that it can be reused by other later queries.

Feedback

Edit on GitHub

Forums