7 min read

Caching That Respects the User's Data Plan

Most Android apps ship with no HTTP cache configured and re-download the same payload every cold start. How to set up OkHttp caching, get 304s from your backend, size images properly, and honour metered connections and Data Saver.

AndroidNetworkingOkHttpPerformanceCaching
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — OkHttp's disk cache is off until you pass it a directory, so most apps re-download identical payloads on every cold start. Configure it, then find out whether your backend actually sends ETag and Cache-Control — a 304 Not Modified costs a few hundred bytes instead of a few hundred kilobytes. After that the wins are: request correctly-sized images (usually the single biggest saving in the whole app), gate prefetch on isActiveNetworkMetered / WorkManager's UNMETERED constraint, and obey Data Saver. Then measure bytes per session — Play Console won't tell you.

Who this is actually for

Not for the phone on your desk. For the user on a prepaid bundle where 2MB has a price, on a network that charges by the megabyte, on a device where the radio waking up repeatedly is a visible battery cost.

"It's only a couple of megabytes" is a sentence written by someone on unlimited fibre. For a meaningful share of your install base, data is money and radio time is battery. That reframing is what makes the rest of this worth an afternoon.

Step 0: turn the cache on

OkHttp ships a full HTTP cache implementation. It is inert until you give it a directory and a size.

val cache = Cache(
    directory = File(context.cacheDir, "http_cache"),
    maxSize = 50L * 1024 * 1024,   // 50 MiB
)

val client = OkHttpClient.Builder()
    .cache(cache)
    .build()

Use cacheDir, not filesDir — the system may reclaim it under storage pressure, which is the correct behaviour for a cache and not for your database. Size it against your payloads; 20–50 MiB is a reasonable starting point for a JSON-heavy app, and images should be handled separately (see below).

Verify it works rather than assuming. response.cacheResponse != null tells you the answer came from disk, and Android Studio's Network Inspector shows the same thing for a whole session.

Step 1: find out if your backend cooperates

This is the step client engineers skip, and it decides everything above it.

curl -sSI https://api.example.com/v1/feed | grep -iE 'etag|cache-control|last-modified'

Three outcomes:

  • ETag present. Excellent. OkHttp automatically sends If-None-Match on the next request, and the server can answer 304 Not Modified — a header-only response. A 200KB feed becomes ~200 bytes, and your users' data cost for a refresh drops by three orders of magnitude.
  • Cache-Control: max-age=… present. Repeat requests inside the window are served entirely from disk with no network at all.
  • Cache-Control: no-store on everything. Very common, usually because a security review asked for it once and nobody scoped it to the endpoints that hold personal data. Your cache directory will never fill, and no client-side code changes that.

If it's the third, that's a backend conversation, not a client fix. Raise it with concrete numbers — bytes per session before and after — because "please add ETags" without a number loses to every other priority.

Meanwhile you can ship a stopgap:

// WORKAROUND: backend sends no-store on /feed (TICKET-4821).
// Remove once ETags land — this overrides the server's stated policy,
// which is only defensible because we know the data is public and slow-moving.
private val rewriteCacheHeaders = Interceptor { chain ->
    val response = chain.proceed(chain.request())
    if (chain.request().url.encodedPath.startsWith("/v1/feed")) {
        response.newBuilder()
            .header("Cache-Control", "public, max-age=300")
            .removeHeader("Pragma")
            .build()
    } else {
        response
    }
}

Two things make that acceptable: it's scoped to one endpoint whose data you understand, and the comment says it's a workaround with a ticket. An unexplained header rewrite is how an app ends up serving stale prices.

Never do this for authenticated, user-specific or transactional responses.

Step 2: images, which are where the bytes actually are

For most apps, images are 80–95% of transferred bytes and the JSON debate is a rounding error.

The dominant waste is not caching — it's size. A 2000×1500 photo downloaded to fill a 120dp avatar transfers roughly two hundred times the pixels the screen can use. Every decent CDN can serve a variant:

AsyncImage(
    model = ImageRequest.Builder(context)
        .data(user.avatarUrl.withWidth(dpToPx(120)))   // ask the CDN for the right size
        .memoryCacheKey(user.id)
        .crossfade(true)
        .build(),
    contentDescription = null,
)

Coil and Glide both keep their own disk cache, sized independently of the OkHttp one — don't rely on the HTTP cache for images and don't double-count the budget. And prefer modern formats: WebP or AVIF instead of PNG typically halves the payload for the same perceived quality.

If you do one thing from this article, make it this section.

Step 3: know what network you're on

class NetworkPolicy(private val cm: ConnectivityManager) {

    val isMetered: Boolean get() = cm.isActiveNetworkMetered

    val isDataSaverOn: Boolean
        get() = cm.restrictBackgroundStatus ==
            ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
}

Two decisions follow:

Prefetch only on unmetered. Preloading the next twenty feed items is a good experience on WiFi and a bill on cellular. For background work, make it declarative and let the system schedule it:

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.UNMETERED)
    .setRequiresBatteryNotLow(true)
    .build()

Obey Data Saver. RESTRICT_BACKGROUND_STATUS_ENABLED means the user has explicitly told the platform they are rationing data. Continuing to prefetch is not a clever optimisation; it's overriding a stated preference — and it's exactly what a system-level setting exists to prevent.

Degrade rather than fail: on a metered connection, load the thumbnail instead of the full asset, skip the autoplaying video preview, and fetch the next page on demand instead of ahead of time.

Step 4: measure bytes per session

None of this survives a redesign unless it's a number someone watches.

Android Studio's Network Inspector gives you a precise per-session breakdown while you're working. TrafficStats.getUidRxBytes(Process.myUid()) sampled at session start and end gives you a rough real-world figure to log — good enough to spot a 3× regression, which is the class of problem that actually ships.

Log it by screen, and put the cold-start figure on a dashboard. "The feed costs 2.4MB on first open" is a sentence a product team can make a decision about. "Our networking should be more efficient" isn't.

The rule

Caching isn't a performance optimisation — it's a courtesy you can measure. Turn the cache on, get your backend sending ETags, ask the CDN for images the size you're actually going to draw, and let the user's declared preferences win over your prefetching. Then watch bytes per session, because the thing nobody measures is the thing that quietly triples.

Keep reading