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
- 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
- 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.
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.
Demo
| 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) |
iOS
- 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
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)
}
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
- 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.








Comments