Getting Fresco to respect HTTP response cache headers

Fresco (http://frescolib.org) is a very popular image loading library for Android from the folks @Facebook. It’s also the library of choice for the Android team here @ASOS, due to its flexibility in handling complicated image requests (e.g. low and high resolution parallel loading of images), its buttery smooth performance and its very robust memory management and bitmap recycling model.

We’ve been extremely happy with our choice, but we recently realised that, due to some misunderstanding on our part of the default behaviour of the library and due to not very explicit documentation, we have introduced a few bugs into our app caused by misconfiguration of the library.

The first issue we noticed, a few months back, is that some of our most dedicated users would see our app use up to 1GB (!) of storage. This turned out to be a simple misunderstanding of the default caching configuration of Fresco.

According to the official documentation, all you need to do to start using Fresco is this:

Fresco.initialize(this)

The problem is that, by default, Fresco will initialise a disk cache with no max limit. As you can imagine, this can be problematic. The fix was simple:

val imagePipelineConfig = OkHttpImagePipelineConfigFactory
.newBuilder(context, okHttpClient)
.setMainDiskCacheConfig(DiskCacheConfig.newBuilder(context)
.setMaxCacheSize(100L * ByteConstants.MB)
.setMaxCacheSizeOnLowDiskSpace(10L * ByteConstants.MB)
.setMaxCacheSizeOnVeryLowDiskSpace(5L * ByteConstants.MB)
.build())
.build()
Fresco.initialize(this, imagePipelineConfig)

This is very nice, since it also allows Fresco to trim its disk cache in situations where the system is running low on disk space.

The second issue we noticed, a few months after the first one, is that our images would not refresh when the HTTP headers dictated (notice above that we use the class OkHttpImagePipelineConfigFactory, provided by Fresco, which allows the use of OkHttp as the network layer used to load images).

We had assumed that Fresco would respect the response cache of the provided network library, but that turned out not to be the case. Fresco always uses its own disk cache, which has an eviction policy of 60 days per image. So regardless of what our image server’s response headers said, all images in our app would be cached for 60 days (while there was still space left on the device).

Our initial approach was to simply disable the Fresco provided disk cache. The assumption here was that if Fresco has no disk cache to fall back to, it will always try to load the images from network and then the OkHttp response cache layer would kick in.

Unfortunately, at the time of writing, there is no easy way to globally disable the disk cache for Fresco. There is a way to disable the use of the disk cache on individual image requests like so:

ImageRequestBuilder.newBuilderWithSource(Uri.parse(imageUrl)).disableDiskCache()

We decided to try that, and added the above in all the places that we created image requests – and as an added bonus, we set the disk cache configuration to a max of 0 bytes (this would clear the space used by the cache for upgrading users):

val imagePipelineConfig = OkHttpImagePipelineConfigFactory
.newBuilder(context, okHttpClient)
// no way to disable the disk cache globally, setting everything
// no to max 0 bytes
  .setMainDiskCacheConfig(DiskCacheConfig.newBuilder(context)
.setMaxCacheSize(0)
.setMaxCacheSizeOnLowDiskSpace(0)
.setMaxCacheSizeOnVeryLowDiskSpace(0)
.build())
.build()
Fresco.initialize(this, imagePipelineConfig)

The 60-day caching problem was resolved. Unfortunately, we now had no caching at all. Regardless of the cache headers in the HTTP response, the images would always load from the network.

After lots of debugging, we pinpointed the problem. The default OkHttpImagePipelineConfigFactory, provided by Fresco, assumes that the library will handle all caching and therefore the OkHttp response cache is disabled by adding a ‘Cache-Control: no-store’ header in the request, which is later used by OkHttp when deciding whether to add the response in the cache.

The solution was, again, quite simple:

/**
* The default [OkHttpNetworkFetcher] injects a ‘Cache-Control: no-store’ 
* header in the requests, preventing the response cache to work for images loaded 
* via Fresco. This is done because Fresco has its own disk cache which, 
* unfortunately, ignores the response cache headers. <br><br>
*
* We decided to disable that disk cache and rely on the http one, so we need to 
* remove the ‘no-store header from the request 
**/
class OkHttpNetworkFetcherWithCache : OkHttpNetworkFetcher {
constructor(okHttpClient: OkHttpClient) : super(okHttpClient)
constructor(callFactory: Call.Factory, cancellationExecutor: Executor) : super(callFactory, cancellationExecutor)
override fun fetchWithRequest(fetchState: OkHttpNetworkFetchState, callback: NetworkFetcher.Callback, request: Request) {
super.fetchWithRequest(fetchState, callback, request.newBuilder()
.cacheControl(CacheControl.Builder()
.build())
.build())
}
}

and:

val imagePipelineConfig = ImagePipelineConfig.newBuilder(context)
.setNetworkFetcher(OkHttpNetworkFetcherWithCache(frescoOkHttpClient))
// no way to disable the disk cache globally, setting everything 
// no to max 0 bytes
.setMainDiskCacheConfig(DiskCacheConfig.newBuilder(this)
.setMaxCacheSize(0)
.setMaxCacheSizeOnLowDiskSpace(0)
.setMaxCacheSizeOnVeryLowDiskSpace(0)
.build())
.build()
Fresco.initialize(this, imagePipelineConfig)

Notice that we are now using a separate OkHttp client, specifically created for Fresco, so that we don’t share the response cache between normal API calls and image URLs. This will prevent most of our cached API responses from being evicted when large images are loaded in the same cache.

The size of the new disk cache for our images is now configured at the OkHttp layer as expected:

val frescoOkHttpClient = OkHttpClient.Builder()
.connectTimeout(DEFAULT_REQUEST_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(DEFAULT_REQUEST_TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(DEFAULT_REQUEST_TIMEOUT, TimeUnit.SECONDS)
.cache(newCache("fresco_cache", 100, ByteConstants.MB))
.build()
 
fun newCache(name: String, mb: Long, unit: Int): Cache =
Cache(File(applicationContext.cacheDir, name), mb * unit)

One last thing to notice is that by doing this, we lose the ability to display cached images that have expired when there is no network. Fresco’s default behaviour is technically the same, but with a 60-day eviction policy, this was rarely an issue. Moving to proper HTTP cache controls, this might be a problem depending on your use case. We decided that this was important for us so we created a custom interceptor that will retry the network request on an IOException with the addition of a ‘force cache’ cache control.

class StaleIfErrorInterceptor: Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
return try {
chain.proceed(request.build())
} catch (e: IOException) {
Log.w(TAG, "Error on network call. Fallback to cache if present.", e)
chain.proceed(request
.cacheControl(CacheControl.FORCE_CACHE)
.build())
}
}
}

and:

val frescoOkHttpClient = OkHttpClient.Builder()
.connectTimeout(DEFAULT_REQUEST_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(DEFAULT_REQUEST_TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(DEFAULT_REQUEST_TIMEOUT, TimeUnit.SECONDS)
.addInterceptor(StaleIfErrorInterceptor())
.cache(newCache("fresco_cache", 100, ByteConstants.MB))
.build()

Our actual interceptor is a bit more complicated than the above since we apply it to all our OkHttp clients and control whether it should fall-back or not via a custom header in our retrofit interfaces, but that’s a topic for another blog post.

With all this in place, our image loading pipeline now fully respects HTTP caching, with ETag support (since our image server supports it). For more information about HTTP caching, you can read Mozilla’s excellent guide here.

Leave a comment