Handling Caching in Mobile Apps

After working on a few API driven mobile apps, I've gained a real appreciation for the challenges of caching.

Every time you fetch data from an API, you have a few options:

  1. Don't store it
  2. Store the result in memory
  3. Store the data on disk

Don't store it

If you don't store the responses of API calls, every time you need the data you'll need another network request.

The advantage is the data is never stale, and you're minimising RAM / Disk usage.

However, you're increasing server load, and using mobile data. The app won't work while offline, and the app's responsiveness will suffer when the connection is poor.

Store in memory

If you store the responses in memory, then you'll make fewer network calls, and the app will feel more responsive.

The downside is the data can get stale if you cache it for too long, and the data is lost when the app is restarted (however, there are also benefits to this).

Storing in disk

Similar to storing in memory, except the data persists when the app restarts (using CoreData in Swift, or the Keychain for sensitive data). Care has to be take not to cache too much to disk, to prevent an unacceptable app size.

Push notifications

The problem with caching is knowing how long to cache the data for. With push notifications, the server can tell the mobile client when data is stale. This is ideal, but the technical implementation is more complex.

Solution

In my own apps, I combine all of the above approaches.

Most data is cached in memory. If that data is likely to get stale quickly, then the cache is only read from if the network isn't available.

Data is only cached to disk if it needs to persist after a restart.

Here's a simple example of caching in Swift:

import Foundation

public class CachingHelper<I: Codable> {

    private var _cached: I? = nil
    private var cacheUpdatedAt: Date = Date()
    var cacheDuration: TimeInterval!

    var cached: I? {
        get {
            let now = Date()
            if now.timeIntervalSince(self.cacheUpdatedAt) > self.cacheDuration {
                self._cached = nil
            }
            return self._cached
        }
        set(value) {
            self._cached = value
            self.cacheUpdatedAt = Date()
        }
    }

    public init(cacheDuration: TimeInterval = 300) {
        self.cacheDuration = cacheDuration
    }
}

// Using the CachingHelper:

struct User: Codable {
    var name: String
}

// Instead of an actual API call, for demo purposes:
func getSomeAPIData() -> User {
    return User(name: "Bob")
}

class UserManager {
    static let shared = UserManager()
    private var cachingHelper = CachingHelper<User>()

    func fetch() -> User {
        if let users = self.cachingHelper.cached {
            return users
        }
        let users = getSomeAPIData()
        self.cachingHelper.cached = users
        return users
    }
}

print(UserManager.shared.fetch())

API granularity

Related to these issues is how granular you make an API.

To minimise the number of network calls, you might decide to have a single API endpoint which returns most of the data the app requires to operate.

The problem here is you need to set a single cache expiry time for all of that data. So if you can't afford for a single piece of that data to become stale, you need to pull it all down again quite frequently.

When composing APIs, it makes sense to bear this in mind, and split long lasting data into a separate endpoint to data which become stale much faster.

Conclusions

It's important to have a clear strategy around caching in mobile apps. It was useful for me to work through it in this article, and hopefully it's useful for others too.