Skip to main content

For On-Device Caching, Use Your Head(ers)

 When designing an app feature that uses data from an API, it's common to have a debate about storing that retrieved data locally on the device.  This can be for performance reasons, or to provide an offline-first experience for the user.  But, before starting to debate the pros and cons of introducing a database layer (or worse, considering storing entire JSON payloads to the device filesystem!), consider this: you might get the on-device storage behavior you want, for free, with no code.

Before we go into full infomercial mode here, let me explain.  Whether you realize it or not, when you make an HTTP(S) request in your native Swift or Kotlin code, the server can hint to how the client should handle storing the response, and the underlying network SDK acts on these hints (usually with no indication as to what it's doing!).  In my experience on projects, we don't spend enough time in API design talking about this, and even less time on the frontend team discussing how it affects the app.  Yet, the app will act differently based on these server hints.  So, like any other infomercial hawker who is promising free stuff, I will prove that using these headers will give you free caching.. just without the overenthusiastic testimonials.

The Server Rules

The rules about how a server tells the client app how data should be cached were hashed out for use with web browsers long ago.  For fans of dry documentation, you can curl up with RFC 9111.  When you request API data using the built-in URLSession and friends, or use Retrofit/OkHttp, these are designed to follow the same rules that web browsers use.
In iOS, this is enabled by default, but in OkHttp, this must be opted into ().  See the Android Demo section for details.  
In short, when requesting HTTP(S) data from your app's backend, the API response may be sent with a "Cache-Control" response header.
There are quite a few valid values for the " Cache-Control " header, but the most important ones are:
  • no-cache: this asks the client to not cache the response data
  • no-store: this asks the client to not cache or store the response data
  • max-age: this provides a number of seconds the client can store the data
" no-cache " and " no-store " are mostly self-explanatory, so let's talk about " max-ag e".  When the server provides a " Cache-Control: max-age=x " response header, some interesting things happen on the client:
  • For the first request of the data, the client requests the data from the API
  • Seeing the max-age value, the client stores the data in a local data cache
    • This can be in memory or on the filesystem, but it's more likely it will be stored within the app container files somewhere
  • For any subsequent request of the same API data, the client checks the local cache and the age of that cached data
    • If the age of the data is greater than max-age seconds, the data is retrieved and stored again
    • If the age of the data is less than max-age seconds:
      • The data is retrieved from the local store
      • Data returned from the networking layers as if there had been a successful 2xx family HTTP response - there is no indication that this was retrieved from cache
      • The client never attempts any network operation, so everything happens locally... meaning quickly

To Cache or Not to Cache?

Believe it or not, the decision about which Cache-Control header to send with an API response is a business rules question, not a 100% technical question.  By sending a header with your API response, you are effectively defining a "retention policy" and deciding just how stale data can be and still be shown to the end user.  Depending on the business rules of your domain, showing cached data can be a great performance gain or a big, expensive legal problem.  But rather than punk out with the traditional "it depends" answer, here are some recommendations that might help.

Cache with "max-age":

  • Reference data: 
    • Lists of valid values, catalog/product data, list of events (games, concerts, flights, etc) that rarely change
      • Strategy: rather than get wrapped around the spokes in a discussion of "what if this data does ever change???", direct the discussion towards the likelihood of change and the acceptable time that stale data could be shown.  Remember that the mobile app user is typically a "snacker": they are popping into the app, accomplishing a task, then off they go.  So, even setting max-age to a few minutes or an hour can make a big difference in app responsiveness and preventing hits to your API backend.
      • Exceptions
        • If your catalog/product data is merged with inventory availability data (how many can I buy now?) in one API response, it may not be appropriate to cache.
        • If your reference data isn't truly static (depending on other data, like "sold out" status), it may not be appropriate to cache.
  • Content-related data: 
    • Images.  Make sure your image-serving system makes use of  Cache-Control  headers.
    • Data from a Content Management System, like carousel/card/display data for promotions, seasonal themes or other infrequently-changed, non-transactional data.
This app uses weather data whose source is only updated every 15 minutes.  Why retrieve this fresh every time the screen is displayed?

No-Cache/No-Store

  • Shopping cart data
  • Orders and order status
  • Any sensitive data (HR data, PII type data, transaction data, etc)
  • Reservation data
  • Inventory availability data (number of items remaining, seats remaining, etc)
    • Depending on business rules, you may be able to use a very short max-age value on some of this data.   This might help with flows where a screen is shown but there are options on the screen to briefly dive deeper into the navigation hierarchy, only to return to the first screen.  By deciding on some reasonable maximum time that stale data might be shown, you can avoid API request churn.
With some backend implementation technologies, it may be possible to send a 404/Not found response with a Cache-Control header value.  This is almost never correct.  If the data is not found, this might be a temporary condition, like the product is not yet available, and this fact shouldn't be cached.

Demo

For the demo, I've stood up a fakey API layer with the following endpoints, each with different " Cache-Control " response headers:
URL Description Cache-Control Value
/products/{product_id} Product catalog data max-age=60
/orders/customer/{customer_id} Customer order, status nocache
/customers/{customer_id} Customer data nostore
/promotions/product/{product_id} Per-product promotional text (none)
I've also created small client apps for iOS and Android that retrieve data from these endpoints.  The source for these is available on Github.  Feel free to view the Swagger UI docs.

iOS

This demo iOS Swift Playground is available via GitHub for you to follow along.  
All endpoint calls are very simple URLSession.dataTaskSession(for:) requests.  By default, iOS stores the data from the NSURLCache into the app's "caches" directory.  The cache data is stored as a sqlite database that we can query to see the effects of using the different values for the Cache-Control header in our API.  For convenience, I print out where you can find the playground's cache directory on your Mac to the app console on app startup.
After launching the playground, you will see a simple list with a row for each endpoint in our fake API.  To call each API, tap on the refresh icon.  Here are the results from calling each in order:

When tapping on the each row, the app will display some of the demo data ("ProV1 Golf Balls", "1001", "Tom Brady", "Free shipping.."), the value of the HTTP Header "Date" (when the server says the data was retrieved), and the value of the Cache-Control header for its corresponding API call.  Seeing the value of the "Date" response header is helpful to see when data is actually retrieved from the server.
  • For the Product Data, with value of "max-age=60", try refreshing the data a few times in a row.  Note that the value for the Date header is not updated.  This is because the data is retrieved from local cache, and no actual network operation happened, so we see the value of the Date header from the previous API call.  In the sqlite cache database, we can see that the API response is stored:

    Wait a little more than 60 seconds, and tap the refresh icon again. You will see an updated value for the Date header, because the cache has expired and iOS made a network call to retrieve the new data from the API.
  • For the Orders Data, with the value of "nocache", try refreshing the data again after the first time.  Note that the value for the Date header is different each time.  This makes sense, because the data is obtained from the network each time and is not being cached.  In the sqlite cache database, we see two rows, representing the Product and Orders data responses:
    Wait a minute... the cache database contains the Orders Data API response!  iOS has stored the entire response, with headers and all, in the cfurl_cache_blob_data table and knows not to return a cached response on subsequent requests for this API.  However, the data remains stored in the sqlite database.
  • Remember that for the Customer Data, the server will respond with the value "nostore".  As with the Orders data, note that the value for the Date header is different each time, indicating fresh data is obtained every time you tap the icon.  In the sqlite cache database, we still only see data for two responses (Product and Orders):

    Because of the "nostore" header value for Cache-Control, the API response is not saved to the cache database. This highlights the very clearly different behaviors in iOS between using " nocache " and " nostore ".
    Pen testers (and we assume other degenerates) will look in the sqlite cache for payloads containing sensitive data because iOS stores them in this unencrypted database. If you have any API responses that contain sensitive data, ask your backend team to send the response with " nostore " instead of no value or " nocache " - don't fail your next pentest!
  • Lastly, for the Promotions Data, which does not set an explicit value for the Cache-Control header, note that the Date header value is refreshed each time the icon is tapped.  The standards around sending no header state that clients should not cache these responses, and iOS does not cache them.  In the sqlite database, we see this response data, as we did with the " nocache " value:

Android

This demo Android app is available via GitHub for you to follow along.  
All endpoint calls are very simple Retrofit requests.  By default, remember that Android does not cache responses when using OkHttp/Retrofit.  The important part of the demo code that enables this is in the NetworkModule.kt file:  

      private fun provideHttpClient(@ApplicationContext appContext: Context): OkHttpClient.Builder {
        return OkHttpClient
            .Builder()
            // https://square.github.io/okhttp/features/caching/
            .cache(
                Cache(
                    directory = File(appContext.cacheDir, "http_cache"),
                    maxSize = 50L * 1024L * 1024L // 50 MiB
                )
            )
            .readTimeout(60, TimeUnit.SECONDS)
            .connectTimeout(60, TimeUnit.SECONDS)
    }
  
The cache data is stored as plain files, so it is easy to see the effects of using the different values for the  Cache-Control  header in our API.  The directory named "http_cache" will be in the simulator's filesystem in the app's directory under /data/data.  To view it, use the "Device Explorer" tool in Android Studio.
After launching the app, you will see a simple list with a row for each endpoint in our fake API.  To call each API, tap on the refresh icon.  The results are similar to the iOS section, so I won't repeat them here.  Unlike iOS, you can simply refresh/"synchronize" the directory within "Device Explorer" to see the file modification date instead of having to execute a sqlite query after each result (the files ending in .1 are the full JSON payloads).  As with iOS, when the server responds with "nocache", a response file is created and when the server responses with "nostore", a file with a response is not created.
The results of the "http_cache" directory:
Note that only three sets of files are created, since one was not created for the endpoint that responded with the "Cache-Control: nostore" header.

Recommendations

With these results in mind, here are some recommendations for apps and their backend APIs:
  • Always discuss what the desired  Cache-Control header values should be with your backend team when implementing a new feature.  If re-using an existing API call, and the API does not return a value for this header, discuss a small change to introduce a value.
    • Discuss the appropriateness of caching the data on the frontend, from the perspective of the end user experience
    • Almost all API responses can be considered for a non-zero " max-age " value!
  • Always use an explicit value for the Cache-Control header
    • Yes, the standards say to not cache data without an explicit value, but it's better to be clear about the intent of the data by setting a value.
  • Use the " nostore " value whenever data might be considered sensitive
  • In iOS, it's possible to use a different cache policy, one of the values of NSURLRequestCachePolicy, with your HTTP request.  This will override whatever the value for the Cache-Control header response is.  I recommend never using this technique because it changes the behavior used with the standard header request values.  Especially when specified differently on different API call, this can make it very challenging to predict how the data will be cached and stored.  It also goes against our principle of having the server declare the "retention policy" of data to all of its clients - iOS, Android, Web, etc.

If you're feeling that you would like even finer-grained control over client caching, but without custom code, there's an even deeper rabbit hole for you to explore.  In a future post, I'll cover the " etag " response header, which allows for more flexible caching with the cost of amping up the complexity to 11.


Comments