Teaser Image

qnoid

Markos Charatzas - London, UK




Introduction

This post is based on Xcode 7.3 and iOS 9.3

All the information in this post can be found in the iOS documentation but the support for computing if a response is "fresh".

This post pieces all the information together to form a primer in HTTP caching and its native support by iOS. This is by no means an exhaustive post on HTTP caching on iOS.

Still, this is useful if you want to understand what iOS gives you out of the box, especially if you are using an Amazon S3 via HTTP. (i.e. to store and retrieve images).

What does the iOS documentation say

NSURLSession uses the NSURLSessionConfiguration.defaultSessionConfiguration, which uses the NSURLCache.sharedCached for its URLCache.

From NSURLSession's documentation,

For a data task, the NSURLSession object may call the delegate’s URLSession:dataTask:willCacheResponse:completionHandler: method. Your app should then decide whether to allow caching. If you do not implement this method, the default behavior is to use the caching policy specified in the session’s configuration object.

From NSURLSessionConfiguration's documentation

Set this property to one of the constants defined in NSURLRequestCachePolicy to specify whether the cache policy should depend on expiration dates and age, whether the cache should be disabled entirely, and whether the server should be contacted to determine if the content has changed since it was last requested.

The default value is NSURLRequestUseProtocolCachePolicy.

**Figure 1* NSURLRequestUseProtocolCachePolicy decision tree for HTTP and HTTPS*,

NSURLRequestUseProtocolCachePolicy decision tree for HTTP and HTTPS Copyright © 2016 Apple Inc. All rights reserved. Updated: 2015-10-06

Briefly put:

  1. If a cached response does not exist for the request, the URL loading system fetches the data from the originating source.

  2. Otherwise, if the cached response does not indicate that it must be revalidated every time, and if the cached response is not stale (past its expiration date), the URL loading system returns the cached response.

  3. If the cached response is stale or requires revalidation, the URL loading system makes a HEAD request to the originating source to see if the resource has changed. If so, the URL loading system fetches the data from the originating source. Otherwise, it returns the cached response.

How-to

Let's see it in action. Given this sample code,

    let session = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration())

    let sessionDataTask = session.dataTaskWithURL((NSURL(string: "http://qnoid.com/assets/images/avatar.jpg")!)) { [weak imageView = self.imageView] data, _, _ in

        guard let _imageView = imageView else {
            return
        }

        dispatch_async(dispatch_get_main_queue()){
            _imageView.image = UIImage(data: data!)
        }

    }

    sessionDataTask.resume()

This is the HTTP trace (mitmproxy trace)

enter image description here

Notice that this response (i.e. coming from an AWS S3 bucket, "Server: AmazonS3") has an ETag but only a Last-Modified header.

Let's see if NSURLCache has stored both the Etag and the Last-Modified headers. In Xcode, press SHIFT+CMD+2 to open "Devices".

Under "Installed Apps" select the app, right click, "Download Container..."

enter image description here

Open the terminal, navigate to the location of the container. Use grep to search. Bash will find a string even if it's inside a binary. Et voilà!

enter image description here

With the NSURLRequestUseProtocolCachePolicy decision tree and the response headers in mind, let's see how iOS uses this information to decide whether to use a cached response or not.

This response does not "require revalidation every time" (e.g. a header of "Cache-Control: must-revalidate" is not present) so iOS will check if "response stale". According to the documentation above, iOS considers a respone stale if it's past its expiration date. The server however does not specify an explicit expiration time, "using either the Expires header, or the max-age directive of the Cache-Control header." (13.2.1 Server-Specified Expiration)

When Server-Specified expiration is not available, HTTP states that "In order to decide whether a response is fresh or stale, we need to compare its freshness lifetime to its age".
13.2.4 Expiration Calculations, Heuristic Expiration

However, this is an implementation detail as "the cache MAY compute a freshness lifetime using a heuristic". The spec goes on to suggest an algorithm like so.

Also, if the response does have a Last-Modified time, the heuristic expiration value SHOULD be no more than some fraction of the interval since that time. A typical setting of this fraction might be 10%.

Turns out the cache used in NSURLSession does compute a fresness lifetime (Apple Developer Forums, an account is required), using the algorithm suggested in the spec.

Using the above response as an example, let's calculate the freshness_lifetime.

  freshness_lifetime = (date_value - last_modified) * 10% =>

  ([Sat, 09 Apr 2016 10:26:51 GMT] - [Sun, 17 Jan 2016 14:15:33 GMT]) * 10% =>

  [82 days, 19 hours, 11 minutes and 18 seconds] * 10% =>
  7,153,878 * 10% = 715388 seconds = 8 days 6 hours 43 minutes 8 seconds

"The calculation to determine if a response has expired is quite simple:"

  response_is_fresh = (freshness_lifetime > current_age)

where current age can be loosely calculated as

current_age = now - date_value

So the response will go stale in 8 days 6 hours 43 minutes 8 seconds from the date it was put in the cache.

With all that in mind, let's go through the NSURLRequestUseProtocolCachePolicy decision tree from the beginning.

  1. The request is made
  2. "Cached response exists"?
    1. No
    2. "Fetch the response"
    3. Put it in the cache along with the "Date, "Last-Modified" and "Etag" headers

A day has passed.

  1. The request is made
  2. "Cached response exists"?
    1. Yes
    2. "Require revalidation every time?"
      1. No
      2. "Response stale"?
        1. No
        2. "Return the cached response"

In case of a non stale (i.e. fresh) response, no request is made as the response is returned from the local cache. Don't expect to see a network call. That's good!

8 days 6 hours 43 minutes 9 seconds have passed.

  1. The request is made
  2. "Cached response exists"?
    1. Yes
    2. "Require revalidation every time?"
      1. No
      2. "Response stale"?
        1. Yes
        2. "Issue a HEAD* request"
        3. "Changed?"
          1. No
          2. "Return the cached response"

Notice how iOS issues a GET rather than a HEAD method. AFAICS, this is to avoid issuing two network requests in order to fetch the resource.

enter image description here

In the case of a 304 Not Modified, there is no content as the response will be returned from the local cache.

enter image description here

Conclusion

NSURLSession, NSURLSessionConfiguration and NSURLCache support caching of entity tag validators (aka ETag) and cache invalidation based on how "fresh" a resource is.

A few notes.

  • Once a response has been added in the cache; the longer the time since "Last-Modified", the longer it will take to become stale. e.g.

    • "Last-Modified" in the last 24 hours, stale in ~2.4 hours
    • "Last-Modified" in the last 30 days, stale in 3 days.
    • "Last-Modified" in the last 180 days, stale in 18 days.
  • The later a response is added to the cache; the longer it will take to become stale.

  • You have no direct control when the cache expires; strictly speaking with only a "Last-Modified" header you have no control over when the cache updates. e.g. Once a resource has been cached, if the resource is updated on the server, it will only be fetched by iOS only when the cached response becomes stale.

Consider all the above before deciding whether the default configuration supports your needs.

Source code

Kudos mitmproxy
Further reading 13 Caching in HTTP, Hypertext Transfer Protocol -- HTTP/1.1