How Apollo-Android Client Cache Works at Vrbo

Apollo GraphQL client and normalized cache boost performance in the Vrbo Android mobile app

Nikola Dobrijevic
Expedia Group Technology
7 min readJul 15, 2019

--

Loading… (Photo by Tinh Khuong on Unsplash)

HTTP client caching is great. What’s better? Apollo Normalized Cache! Want to understand how it’s being used in the Vrbo mobile owner app? Keep reading…

Apollo caching made us rethink the way we compose our user interfaces and how optimal data flow should work. Our goal was to significantly improve the app’s performance by reducing the number of network calls leveraging service orchestration and normalized caching.

TL;DR

Orchestration moved costly cellular network operations to a hosted service environment by replacing several network requests with one. The normalized cache allows us to slice and dice the payload as needed. Effectively eliminating loading states while the user navigates through a master-detail flow.

Let’s start by observing the old app without caching. You can see that when the user goes from Calendar to Reservations Activity the app makes the network request to get all reservations.

In this article, we’ll explore how we’ve eliminated these loading states and made the app feel more responsive. Basic understanding of GraphQL and ApolloClient will be helpful but is not required.

If you’ve ever tried using Apollo-Android you likely discovered that passing GraphQL objects via Intent is not supported. These guidelines helped us decouple the UI components and establish a clean Observer pattern with the data layer via Apollo’s live query.

Apollo client on Android offers two types of caching: HTTP cache and normalized cache. We’ll focus on the latter (you’ll see why).

HTTP cache

HTTP cache represents the classic approach to caching; when two client requests are the same, the second request won't hit the network but rather read from the local cache to reduce network traffic and increase speed. This caching mechanism is also available in the commonly-used Retrofit client.

Normalized cache

This type of caching will normalize the response data so that each GraphQL type is stored as a record in the cache with unidirectional references to its subtypes. This allows for an optimal caching strategy where a type can be individually resolved. This structure enables the cache to be updated by various other queries and mutations that are sharing the same type as part of the result. To simplify, think of normalized cache as a SQL database, where each type has its own table and a reference to another type.

Example: If both QueryLive and MutationProperty respond with a list of Property, the underlying cache will be updated for each operation. After MutationProperty has run the result of QueryLive will change without having to request data over the network again.

After the next mutateProperty request, the following changes happen to a cached recordSet:

Notice that after this mutation, the record with id=2 has updated propertyName.

One important nuance is that even if two queries have the same response type, they are effectively different queries and the response is unknown until the server request is complete. For this reason, we cannot merely use CACHE_ONLY to share the data between them. In this case, Apollo will throw “Failed to read cache response” as the Query signature was not matched. An explanation can be found here: https://github.com/apollographql/apollo-ios/issues/421#issuecomment-457476515. This example below illustrates two queries that fetch a property with Live or Inactive status.

Signature of a query for live properties:

Signature of a query for inactive properties:

How are GraphQL cache keys created?

Apollo cache is fully normalized, meaning that every type must be associated with a unique cache key. These keys are typically associated with an object ID. However, most objects won’t have an ID, and we therefore fall back to synthetically generating cache keys. If there is an object in a node that is not identifiable Apollo won’t be able to resolve the subgraph and the cache will be considered a miss.

Cache Keys are generated by using Apollo’s CacheKeyResolver, and there are typically two ways of doing so.

Query arguments caching

CacheKeyResolver.fromFieldArguments() is a method that generates keys based on the arguments of the query and then works its way down by specifying variables of each sub-query. Apollo will do best-effort caching if no implementation is defined. However, in my experience, this will fail to resolve list elements and the caching chain will be broken. This is because Apollo will generate cache keys by sequentially indexing elements of an array. It will not be possible to update them further unless you have an array of the same size and order to update it with. The indexes simply won't match in a real-life scenario where a subset of elements is being updated. The signature of fromFieldArguments looks like:

fromFieldArguments(field: ResponseField, variables: Variables)

  • field: describes the response type and operation arguments
  • variables: argument values

This operation allows you to resolve an outbound request from cache.

Query response caching

We use theCacheKeyResolver.fieldFromResponse() delegate method to build a unique key from the response payload. As mentioned above, for a type that does not have an ID, we must synthetically generate the cache key. This must be done for each non-primitive type in the response, starting from the root of the query to the last leaf in the payload. Failing to complete this chain will void the cache. This is because cache normalization is all-or-nothing, meaning every type has to have a stable key identifier.

It would be nice if we could denormalize the cache to avoid needing stable identifiers on every object type in our response. That increases the dependency on the underlying model structure and services that we consume. See my open question with the Apollo team about this here: https://github.com/apollographql/apollo-android/issues/980

The response Input data from GraphQL server is sent to our CacheKeyResolver and we then process the Input payload:

fromFieldRecordSet(field: ResponseField, recordSet: Map)

  • field: describes the response type and the information about the operation arguments
  • recordSet: represents the response payload as it comes from the endpoint

As we process the recordSet, we need to create a unique record identifier (cacheKey) for items that will be stored in the cache.

How do we cache in the Vrbo Owner app?

We purposefully built an orchestration GraphQL application service that handles several backend services involved in a single master-details flow in our mobile app. The benefit of this is that multiple calls to services are done within our data center instead of over a high latency mobile network:

In just one network request, the mobile app will receive the information needed to render a master-detail flow such as a calendar screen. We then cache this data and reuse it as the user navigates down into the details, effectively eliminating loading/waiting states.

We now understand that we need to cache each Query used across master-detail flow in order to resolve the recordSet from the root of the Query. Using our orchestration GraphQL service, we can do this efficiently and in one request from the client. But we also know that simply over-fetching data from the server in one query won’t do the trick. Different queries will have different roots, and sharing data will not be possible unless they point to the same location in the cache. We achieve this by remapping the root of the query in fromFieldArguments() so that it points to an equivalent item in recordSet created previously by the orchestrated query. This way we can over-fetch with JoinQuery and leverage cached data when the user navigates to the calendar tab by reading from CACHE_ONLY using a CalendarQuery. In this example, the query root of the CalendarQuery should match the CalendarFragment’s response cache key:

In order to cross-reference cached data between the two queries, we will have to ensure sub-query (CalendarQuery) resolves a key value that matches the calendar type in the superset query (JoinQuery). As mentioned in this apollo-android issue, you will achieve this by using the CacheKeyResolver.fromFieldArguments() function:

The end result — no network loading states. Reservations load instantly by the way of reusing network data with normalized cache:

Try it out by downloading the Vrbo app from Android Play Store!

References and credits

https://github.com/apollographql/apollo-android#support-for-cached-responses

https://blog.apollographql.com/the-concepts-of-graphql-bc68bd819be3

https://github.com/apollographql/apollo-android/issues/821#issuecomment-365944027

--

--