Using URLCache As An Alternative To Retaining Network Model Objects
Oftentimes we request JSON data from the server, deserialise it into network model objects and then wonder where we can keep ahold of this data. We wish to put it somewhere handy in our architecture so that we don’t have to repeat the effort of hitting the server again for the same data in the future.
It’s not always obvious where we can put these objects to make them available between unrelated screens - they can’t necessarily be neatly passed around. There are many approaches to solving this architectural problem, and we could go as far as rolling our own data store (BlahDataManager
), or even cramming what we get back from the network into Core Data - occasionally the correct call but usually a vast over-complication.
Whatever we choose, we’ll likely arrive at the following solution, with two separate APIs to query for each set of data that we might need:
- Step 1: query our local data store (whatever it is) to see if we already have the data we need, or..
- Step 2: hit the network to retrieve the data.
This could be wrapped in a single public interface to hide this fiddliness, but what I wanted to show is that - for GET queries at least - there is already a Foundation API for doing this rather neatly: URLCache.
Background
In a client app I worked on recently, there was a situation where the server-side data we needed was scattered over many endpoints (there was an existing API, designed originally for a website, and it wasn’t adjustable for our purposes), and data had to be pieced together from different places, and at different times. Unfortunately the response times from the server were also rather lengthy, and the need to make multiple requests each time we needed certain data was resulting in a poor user experience.
It became pragmatic for the responses to be cached somewhere, but we wanted - for most endpoints, and in the MVP at least - to avoid the extra complexity of maintaining our own local model store representation of the server data, as this would need to be kept in sync across subsequent updates.
What would be nice is if we could skip that and just blithely make all queries for data to our Transport layer every time we needed it, without worrying about how frequently we do this, whether that query has already been fetched before, or how many requests are being made. The idea is that if we could cache at the network layer instead, we could get the benefit of using a single API, still have very quick data accesses, and not have to worry about having to store the result.
This use-case would not apply equally well to all apps out there, but for this app it was particularly suitable, as it was just a bunch of screens displaying fairly static data that only changed predictably and infrequently, yet which each an outsized number of calls to be made.
(NS)URLCache
Using URLCache allowed us to make our network requests efficiently repeatable and, for our simple use-cases, allowed us to completely sidestep the responsibility of implementing our own data storage in the Application layer.
It works like this:
- If the data has never been retrieved before, the request will hit the server as normal & retrieve the data, and then asynchronously return the response.
- If the data has previously been retrieved (and is still cached), it will be immediately (asynchronously) returned to us.
As the caller of this API, we don’t actually care about which path is executed - and the data is returned to us in the same way each time. This made it pretty fast to repeatedly traverse the various endpoints over which user and application data was scattered, without actually hitting the network more than necessary.
Configuration
URLCache is simply a component of the networking stack in Foundation which we can configure to suit this usage. We used URLCache with in-memory storage only (as disk storage is persisted across app launches, which we didn’t want). This should ideally be configured on app startup:
// Assign memory (but not disk space) for a URL network cache:
let cacheSizeMegabytes = 30
URLCache.shared = URLCache(
memoryCapacity: cacheSizeMegabytes*1024*1024,
diskCapacity: 0,
diskPath: nil
)
That’s it! All that’s needed now is to configure your URLRequest with the appropriate cache policy. useProtocolCachePolicy
is the default (which is the most respectful of the cache-related HTTP headers), but returnCacheDataElseLoad
is what we will want to use for all our appropriate endpoints, as described below.
public enum CachePolicy : UInt {
case useProtocolCachePolicy // the default
case reloadIgnoringLocalCacheData
case returnCacheDataElseLoad
case returnCacheDataDontLoad
//.. omitted other unimplemented cases
}
Note: Somewhat unintuitively, despite providing your cache policy as returnCacheDataElseLoad
, URLRequest remains free to disregard your caching preference if the response contains HTTP headers indicating that stuff should not be cached (Cache-Control: no-cache
). In our case, this was arbitrarily turned on for every single request (even for completely static shared data), and we wanted to work around it - I’ll write about how to do this in a follow-up post.
To clear a response from the cache (for example, because a user has triggered a UIRefreshControl
), you just provide an appropriate request that matches up to (i.e. has the same characteristics - it doesn’t need to be exactly the same object) the response you wish to clear:
URLCache.shared.removeCachedResponse(for: fetchRequest)
Limitations
Naturally this approach has its limitations:
- we must manually clear the cache (per-endpoint) if we wish to receive newer data.
- if we fire the same request simultaneously (e.g. from two different parts of the code), two network requests for the same data will still be made (due to the race condition of neither having completed yet - i.e. there’s no cache hit).
- the cache sits prior to deserialisation, so on each access there is some wasted effort deserialising the same JSON over and over again. In practise I have found this not to be a noticeable issue - the usual advice of not prematurely optimising applies here, but it’s something to be aware of.
- the lack of a sophisticated persistence layer (such as Realm or Core Data) means that clearing the cache and refreshing the data does not automatically propagate this change across the app (to other screens, for example). Clearing the cache means that subsequent refreshes will now receive the latest response, but this is not automatically pushed across the app.
In summary, this approach is not suitable for every use case, but for many simple screens that are just showing basic data this can be a nice idea to keep in mind.