Introduction

Mobile applications frequently fetch the same resources—home feeds, category lists, configuration data, and other semi-static content. Repeated requests increase latency, consume bandwidth, and place unnecessary load on backend services.

Retrofit relies on OkHttp for networking, and OkHttp provides built-in disk caching. However, responses are cached only when the server returns valid HTTP cache directives such as Cache-Control. If these headers are missing, every request results in a network call, even when the data hasn’t changed.

This article describes a lightweight client-side caching strategy that enables controlled caching using OkHttp interceptors without requiring backend changes.

Why OkHttp Cache Doesn’t Always Work

Consider a common user flow:

  • The user opens the home screen.
  • Retrofit fetches the home feed.
  • The user navigates away and returns a few seconds later.
  • The same API is called again, even though the data hasn’t changed.

This typically happens because:

  • The server doesn’t return Cache-Control headers.
  • OkHttp’s disk cache is configured but never utilized.
  • Every endpoint follows the same caching policy regardless of its nature.

Although OkHttp supports HTTP caching, it only caches responses that satisfy standard HTTP caching rules. Simply enabling a disk cache isn’t enough.

For a response to be cached:

  • OkHttpClient must be configured with a Cache.
  • The response must contain cache directives such as Cache-Control.
  • The response should be cacheable (typically GET requests).

Without these conditions, every request reaches the server.

Solution Overview

To enable controlled client-side caching, we introduced three components:

ComponentResponsibility
CacheControlInterceptorInjects a default Cache-Control header for eligible APIs when the server doesn’t provide one.
CacheControlUrlRulesMaintains a whitelist of cacheable endpoints.
CacheControlDebugInterceptorLogs whether responses are served from cache or the network.

The caching policy is applied only when:

  • The request URL is whitelisted.
  • The server has not already provided a Cache-Control header.
  • The application is not running in the staging environment.

This ensures that only safe, read-heavy APIs are cached while respecting backend-defined caching policies.

CacheControlInterceptor

CacheControlInterceptor is registered as a network interceptor and acts as the policy layer between the network and OkHttp’s cache engine.

After receiving a response, it performs the following checks:

  1. Verify that the request URL is whitelisted.
  2. Check whether the server already returned a Cache-Control header.
  3. Skip caching for staging builds.
  4. Apply a default cache policy (for example, public, max-age=60) if all conditions are satisfied.
override fun intercept(chain: Interceptor.Chain): Response {
    val request = chain.request()
    val response = chain.proceed(request)

    if (!urlRules.isWhitelistedForCaching(request.url)) {
        return response
    }

    if (response.header("Cache-Control") != null) {
        return response
    }

    if (isStagingEnvironment) {
        return response
    }

    return response.newBuilder()
        .header("Cache-Control", defaultCacheControl)
        .build()
}

Using a network interceptor ensures that the modified response is written to OkHttp’s disk cache before being returned to the application.

CacheControlDebugInterceptor

Once caching is enabled, it can be difficult to determine whether a response was served from cache or fetched from the network.

CacheControlDebugInterceptor, registered as an application interceptor, logs useful debugging information for every request, including:

  • Request URL
  • Cache source (cacheResponse)
  • Network source (networkResponse)
  • Applied Cache-Control header
override fun intercept(chain: Interceptor.Chain): Response {
    val response = chain.proceed(chain.request())

    logger.log(
        "CacheDebug",
        "url=${response.request.url}, " +
        "fromCache=${response.cacheResponse != null}, " +
        "fromNetwork=${response.networkResponse != null}, " +
        "cacheControl=${response.header("Cache-Control")}"
    )

    return response
}

This makes it easy to verify cache behavior during development and QA.

Whitelisting Cacheable APIs

Rather than caching every endpoint, the implementation follows an opt-in whitelist approach. Only explicitly approved APIs receive client-side cache headers.

class CacheControlUrlRules(
    private val whitelistedPathFragments: List<String> =
        DEFAULT_WHITELISTED_PATHS
) {

    fun isWhitelistedForCaching(url: HttpUrl): Boolean {
        val path = url.encodedPath

        return whitelistedPathFragments.any {
            path.contains(it, ignoreCase = true)
        }
    }

    companion object {
        val DEFAULT_WHITELISTED_PATHS = listOf(
            "/api/v1/home/feed",
            "/api/v1/catalog/categories",
            "/api/v1/config/static"
        )
    }
}

This approach helps prevent accidental caching of dynamic or sensitive APIs such as authentication, payments, or user profile endpoints.

Integrating with Retrofit

The interceptors are registered once on the shared OkHttpClient used by Retrofit.

val okHttpClient = OkHttpClient.Builder()
    .cache(Cache(cacheDir, 10L * 1024L * 1024L))
    .addInterceptor(debugInterceptor)
    .addNetworkInterceptor(cacheControlInterceptor)
    .build()

Retrofit.Builder()
    .client(okHttpClient)
    .baseUrl(baseUrl)
    .build()

Since all Retrofit services share the same client, the caching policy is applied consistently across the application.

Estimated Impact

Based on our application’s usage patterns:

Assuming that 20% of repeat home page visits occur within the 60-second cache window, the implementation could potentially:

  • Eliminate approximately 1.4 million network requests per day
  • Reduce backend traffic for the Home Feed API by nearly 20%
  • Save approximately 360 GB of network bandwidth per day (assuming a 250 KB average response size)
  • Improve repeat page load times by serving responses directly from the device’s disk cache

Note: These figures are estimates based on application traffic and a conservative cache-hit assumption. Actual savings depend on user navigation patterns and the cache hit ratio observed in production.

Best Practices

When implementing client-side HTTP caching:

  • Cache only safe, idempotent GET endpoints.
  • Prefer server-provided Cache-Control headers whenever available.
  • Use conservative cache durations (for example, 60 seconds).
  • Disable client-side cache overrides in staging environments.
  • Keep debug logging limited to debug builds.
  • Start with a small whitelist and expand gradually based on usage patterns.

Measuring the Impact

After rollout, monitor the implementation using:

  • Debug interceptor logs to verify cache hits.
  • Network inspection tools to observe reduced API requests.
  • Backend metrics to measure lower request volume for cached endpoints.

For frequently accessed screens, repeat visits within the cache TTL should be served directly from disk without requiring another network request.

Conclusion

OkHttp’s disk cache is effective only when responses include valid HTTP cache directives. When backend support isn’t available, a controlled client-side strategy can significantly reduce redundant API calls.

Author

  • Experienced Mobile engineer with 9+ years of success in designing, developing, and scaling mobile applications. Proficient in Java, Kotlin, Android and cross platform development with React Native. Co-founded and built Binge Digital, a food-tech platform acquired by Dineout (Times Internet).

    View all posts