Thorsten Stark

Reduce your iOS apps network traffic with ETags using URLSession or Moya

Disclaimer: I'm an iOS engineer and no expert for networking or server side configurations. Everything in this article describes how I understand this topic. If you find anything wrong please give me a hint.

Introduction

Many apps rely on network requests to work. Depending on your use case the amount and frequency of those requests may vary. To be a good iOS citizen you should try to reduce your apps network traffic. This saves battery and data volume and can improve app performance by avoiding the need to wait for network requests to finish. But how does the app know when to ask for new data and when not? Luckily there are mechanisms available in the http protocol: ETag and Caching.

What is an ETag?

An ETag is a string in the response header identifying one resource. If on a new request to this same endpoint anything in this response has changed, then the ETag will be different. You can see it as a fingerprint of one response. This feature enables us to know when something in the dataset has changed and needs to get updated in the app.

ETag: "2b837255a2cb690c42da56336b51c894"

How can we use ETag to reduce network traffic

When requesting a data from an endpoint, the server includes the ETag header field in the response. For the next Request to this endpoint, we can send the Etag from the last response in the If-None-Match header field. With this information the server can check if the data it would return has changed since the last time. If there are no changes, the server returns no data and the status code 304 (Not Modified) instead of 200 (Ok). Otherwise it will return the updated response with status code 200 and a new ETag identifying this responses data.

How ETag and Cache work together

Getting a 304 response is a good thing. It means there is no need to transfer unnecessary data over network. But how should we handle this response when there is no data? Usually we use a completion handler to parse the response data and return it as model. As there is no data to parse now this will result in an error. One way would be to store old responses manually and retrieve them when a 304 arrives.

ETag without Cache Header

A better way would be to use a build in mechanism for that: URLCache. By default a URLSession caches a response when its header defines the caching behavior. If there is no cache header specified or it denies caching, then URLSession will not cache the response. When there is a cached response, URLSession will return that cached response once it receives a 304 with a matching ETag. This means our completion handler receives a usual response with status code 200. Another advantage is that as long as the cached response is valid, URLSession won't send any requests but return the cached response immediately.

ETag with Cache Header

Configure the web server

To allow our app to efficiently work with ETags and Caching, the web server needs to support that. Responses must allow caching and have an ETag. Both is set in the header fields:

Cache-Control: public, max-age=36000
ETag: "2b837255a2cb690c42da56336b51c894"

Additionally the server needs to know how to handle a If-None-Match header, when our app sends it.

If-None-Match: "2b837255a2cb690c42da56336b51c894"

Configure the app using bare URLSession

When using URLSession directly you can use the URLSession.shared as it has URLCache.shared as default cache.

let session = URLSession.shared // uses URLCache.shared by default

var request = URLRequest(url: aURL)

// set to use the cache behavior defined by the server
request.cachePolicy = .useProtocolCachePolicy

let task = session.dataTask(with: request) { data, response, error in
    if let httpResponse = response as? HTTPURLResponse,
        let etag = httpResponse.allHeaderFields["Etag"] as? String {
        // store ETag
    }
}

task.resume()

And don't forget to set the cachePolicy for you requests. .useProtocolCachePolicy should be the default, but it wouldn't hurt to set this explicit.

To get the advantage of the stored ETag add it to your next request.

request.addValue(storedEtag, forHTTPHeaderField: "Etag")

Configure the app using Moya

In Moya you first have to define a protocol to make your TargetType cacheable.

protocol CachePolicyGettable {
    var cachePolicy: URLRequest.CachePolicy? { get }
}

Then extend your TargetType with this new protocol.

extension YourTargetType: CachePolicyGettable {
    var cachePolicy: URLRequest.CachePolicy {
        .useProtocolCachePolicy
    }
}

To enable the Moya provider to use this protocol to add the cachePolicy to the request, you need a custom Moya plugin.

final class CachePolicyPlugin: PluginType {
    func prepare(_ request: URLRequest, target: YourTargetType) -> URLRequest {
        guard let policyGettable = target as? CachePolicyGettable else {
            return request
        }

        var mutableRequest = request
        mutableRequest.cachePolicy = policyGettable.cachePolicy

        return mutableRequest
    }
}

Now the last step is to tell Maya about your plugin. This is done when initializing your MoyaProvider.

let provider = MoyaProvider<YourTargetType>(plugins: [CachePolicyPlugin()])

To set the If-None-Match header, a TargetType has a headers property that can help here.

var headers: [String: String]? {
    return ["Etag": storedEtag]
}

Conclusion

Caching responses helps your app to reduce network traffic and therefore increase performance. To take advantage of the built in caching behavior in iOS your app and the server need to support this behavior actively.

Tagged with: