A primer in HTTP caching and its native support by iOS
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,
Copyright © 2016 Apple Inc. All rights reserved. Updated: 2015-10-06
Briefly put:
- If a cached response does not exist for the request, the URL loading system fetches the data from the originating source.
- 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.
- 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)
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…”
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à!
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.
- The request is made
- “Cached response exists”?
- No
- “Fetch the response”
- Put it in the cache along with the “Date, “Last-Modified” and “Etag” headers
A day has passed.
- The request is made
- “Cached response exists”?
- Yes
- “Require revalidation every time?”
- No
- “Response stale”?
- No
- “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.
- The request is made
- “Cached response exists”?
- Yes
- “Require revalidation every time?”
- No
- “Response stale”?
- Yes
- “Issue a HEAD* request”
- “Changed?”
- No
- “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.
In the case of a 304 Not Modified, there is no content as the response will be returned from the local cache.
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.
Kudos
Further reading
13 Caching in HTTP, Hypertext Transfer Protocol – HTTP/1.1