← All problems

Mobile Core Concepts

Caching Strategies

HTTP cache semantics, in-memory and disk LRU, CDN tiers, stale-while-revalidate, and invalidation patterns - the layered cache model every mobile app inherits.

Free~21 min

A mobile app caches at five layers simultaneously, whether the engineer designs for it or not: HTTP cache, in-memory LRU, disk LRU, edge CDN, and the origin's own app cache. Each layer has different cost-to-invalidate, different TTL, and different blast radius when it gets stale. This page covers the semantics every mobile engineer should be able to recite cold - Cache-Control directives, ETag revalidation, LRU eviction, stale-while-revalidate, and the invalidation patterns that hold up under flaky networks and offline reads. Wire-protocol details (HTTP versions, multiplexing) live in Network Protocols; the image-pipeline-specific cache shape lives in Image Loading Internals.

The cache layers

Caching on mobile is layered, not single-tier. A read travels through every layer it can hit, in order, until something answers. Each layer is closer to the user, faster, and harder to invalidate than the one behind it.

LayerLivesLatencyEvictionInvalidation cost
Memory LRUApp process RAM< 1 msProcess death + LRUFree - clear the map
Disk LRUApp private storage5-20 msSize-bounded LRUFree - delete files
HTTP cacheDisk, OkHttp-owned5-20 msRFC 9111 freshnessCache-Control: no-cache from server
Edge CDNProvider POPs20-80 msTTL + tag purgeTag purge or wait for TTL
Origin app cacheDatacenter50-200 msApp-controlledDirect write

The pattern is the same at every layer: store a value with an expiry, evict when capacity is hit. The differences are where the keyspace lives and who can purge it. The app fully controls the top two layers, partly controls the HTTP cache (it sets the request, the server sets the freshness rules), and effectively asks the CDN politely to invalidate via API.

Rendering diagram…

The mental shortcut: closer = faster but smaller and per-user; farther = slower but shared. A CDN cache hit on the same URL serves a thousand users from one origin fetch; a disk hit serves one user once. Design the cache key so each layer is doing useful work, not duplicating the layer behind it.

HTTP cache semantics

The substrate is RFC 9111 (the modern successor to RFC 7234). Every HTTP cache - browser, OkHttp, CDN, reverse proxy - speaks the same response-header vocabulary. Get the headers right and every layer below the app caches correctly without per-platform code.

Cache-Control directives

Cache-Control is the primary knob. The directives that matter on mobile:

DirectiveMeaning
max-age=600Fresh for 600 s; serve without revalidation
s-maxage=600Same, but applies only to shared caches (CDN, reverse proxy)
must-revalidateOnce stale, never serve without revalidating with origin
no-cacheAlways revalidate before serving (304 is OK; saves bytes)
no-storeNever write to disk - for tokens, PII, financial data
privatePer-user; CDN must not store, only the device cache may
publicAny cache may store, even with Authorization header present
immutableBody will never change at this URL; skip revalidation entirely
stale-while-revalidate=60Serve stale up to 60 s past expiry while refetching in background
stale-if-error=86400Serve stale up to 24 h if origin is unreachable

The two most-confused directives are no-cache and no-store. no-cache does cache - it just forces revalidation before each serve. no-store is the actual "do not write this to disk" instruction. For an authenticated /me endpoint, private, no-cache is usually right (cached on device only, validated each time). For an OAuth token response, no-store.

Validators - ETag and Last-Modified

When a cached response goes stale, the client revalidates rather than refetching. Two header pairs make this work:

  • ETag / If-None-Match - the server returns an opaque tag (ETag: "v3-abc123"); the client sends it back on revalidation (If-None-Match: "v3-abc123"). Match = 304 Not Modified with no body; mismatch = 200 with fresh body.
  • Last-Modified / If-Modified-Since - the same dance with an HTTP date. Lower-resolution (1-second precision) and worse for content that can change twice in the same second.

ETag is preferred for anything non-trivial. Two flavors: strong ETags ("v3") mean byte-for-byte identical; weak ETags (W/"v3") mean semantically equivalent (acceptable for content with whitespace or timestamp jitter). A 304 is the win: zero body bytes, headers only, the cached entry's freshness clock resets.

GET /api/posts/abc123 HTTP/1.1
If-None-Match: "v3-abc123"
If-Modified-Since: Tue, 12 May 2026 10:00:00 GMT
 
HTTP/1.1 304 Not Modified
Cache-Control: max-age=600
ETag: "v3-abc123"

Sources: RFC 9111 - HTTP Caching · MDN Cache-Control reference

In-memory and disk LRU

The HTTP cache is one piece. The other two layers - the app's own memory and disk caches - are bounded least-recently-used (LRU) maps that the app controls directly.

AndroidX LruCache

LruCache<K, V> is a synchronized linked-hash-map with size-based eviction. The sizeOf override is the essential piece: capacity is whatever unit you measure in (bytes, items, decoded pixels). For bitmaps, you measure in bytes.

val maxMemKb = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheKb  = maxMemKb / 8           // 1/8 of available app heap is the typical budget
 
val bitmapCache = object : LruCache<String, Bitmap>(cacheKb) {
    override fun sizeOf(key: String, value: Bitmap): Int =
        value.byteCount / 1024         // size unit = KB, matches the capacity unit
}

When put would exceed the cap, LruCache evicts the least-recently-accessed entries until the new value fits. Eviction is only on insertion - the cache never shrinks itself in the background. On low-memory callbacks (onTrimMemory), the app can call evictAll() or trimToSize(maxSize / 2) manually.

OkHttp Cache

OkHttp's Cache is the HTTP cache backend - it implements RFC 9111 freshness, validator handling, and 304 responses, persisted to a disk LRU. Wiring it up is one builder line:

val cacheDir = File(context.cacheDir, "http")
val httpCache = Cache(cacheDir, maxSize = 50L * 1024 * 1024)   // 50 MB
 
val client = OkHttpClient.Builder()
    .cache(httpCache)
    .build()

OkHttp obeys Cache-Control from the server. If the server forgets to send Cache-Control on a public endpoint, OkHttp won't cache it - even though it could safely do so. The Interceptor chain is where you patch this on the client side, e.g. injecting Cache-Control: public, max-age=300 on response from a known-cacheable endpoint that the server didn't annotate.

Choosing capacity

Three rules of thumb that hold up:

  • Memory cache: 1/8 of the process's available heap, no more. Anything larger and the GC pressure starts hurting frame timing more than the cache helps. On a 256 MB heap, 32 MB is a generous bitmap cache.
  • Disk cache: cap at 50-100 MB for typical apps. Larger app categories (offline-first readers, podcast apps) routinely run 500 MB - 2 GB but should expose a "clear cache" affordance in settings.
  • Item count: for non-bitmap caches (JSON responses, parsed objects), 100-500 entries usually covers screen-thrashing patterns without bloating heap.

When to roll your own

Reach for a custom cache only when the LRU eviction order is wrong for the access pattern. Two examples:

  • Frequency-skewed access (LFU) - a few hot items dominate, but they age out of an LRU because of one burst of new misses. LFU (least-frequently-used) or hybrid TinyLFU policies survive bursts better.
  • Hierarchical invalidation - "evict all entries with key prefix user:42:*" requires a structure that indexes by prefix, not a flat LRU.

For 95% of mobile cases, LruCache + OkHttp Cache is the right answer. Reach for Caffeine, Guava Cache, or a hand-rolled structure only when profiling shows the eviction policy is the bottleneck.

Stale-while-revalidate

stale-while-revalidate (SWR) trades freshness for perceived speed. The cached response is served immediately, even if stale, while a background revalidation fetches the fresh copy and writes it back to the cache. The next read after revalidation sees the fresh data.

Cache-Control: max-age=60, stale-while-revalidate=300

The contract: fresh for 60 s. After 60 s, for up to 300 s more, serve the stale entry instantly and kick off a background refetch. After 360 s total, the next request blocks on a fresh fetch like an uncached miss.

When SWR is the right choice:

  • Read-mostly endpoints where slightly-stale data is acceptable - news feed, product listings, user profile. A 30-second-old feed is fine; a 500 ms loading spinner on every navigation is not.
  • Lists that get re-entered frequently - tab switches, back-stack pops. SWR turns those into instant renders.
  • Endpoints behind a CDN - the same directive works at edge (next visitor gets the stale-cached version too, with the background refresh hitting origin once).

The eventual-consistency cost is real: the user sees stale data once, then the fresh version after the next interaction. For data where the user must see the latest (post they just submitted, balance after a transfer), use no-cache and accept the round-trip, or layer in write-through (the mutating request returns the new value, which the client writes into its own cache without a separate fetch).

Sources: RFC 5861 - HTTP Cache-Control Extensions for Stale Content

CDN tiers and cache hierarchy

Server-side caching is itself a multi-tier system. Modern CDNs (CloudFront, Fastly, Cloudflare, Akamai) typically expose three logical tiers:

  1. Edge POPs - hundreds of geographic locations, small individual caches, closest to the user.
  2. Regional / shield tier - fewer, larger caches that aggregate fan-in from many edges. Prevents an edge miss from going all the way to origin.
  3. Origin - the actual application server.

An edge miss on a hot URL still hits the regional shield, which already has the answer warm from another edge's earlier miss. The origin sees a single fetch per (URL, key) regardless of how many edges serve it - the "request coalescing" effect, which is how a CDN survives a homepage on a viral post.

Cache key composition

The cache key is not just the URL. By default it includes:

  • URL path and query string (the obvious part)
  • The Host header (multi-tenant CDNs split per hostname)
  • Anything called out by Vary on the response (Vary: Accept-Encoding splits gzip / brotli / identity into separate cached copies)
  • Often the device class (Cloudflare's User-Agent mobile detection, Fastly's Fastly-FF-* headers)

Auth-realm separation matters: if a response varies by logged-in user, either Vary: Authorization (rarely used because cache hit rates collapse) or Cache-Control: private (don't cache at CDN at all). The wrong move is letting per-user data accidentally share an edge cache slot - the kind of bug where User A sees User B's profile.

A stray Vary is the same hit-rate trap from the other direction: Vary: User-Agent on a response splits the cache by every UA variant and the hit rate falls off a cliff. Normalize at the edge (Cloudflare's "device type" header) or strip Vary for static assets entirely.

Tag-based invalidation

CDN providers expose a tag-purge API: requests responding with a Cache-Tag: post-abc123, feed-home header get tagged in the CDN's cache index. A subsequent API call like PURGE_BY_TAG post-abc123 evicts every cached object tagged with that string across every POP in seconds.

Tag purges are the operational lifeline of any content site - "user edited post abc123" triggers a tag purge, every edge serves fresh from the next miss. Without tags, you either wait for TTL or list every URL to purge manually (impossible for an ID with N derived URLs).

Sources: Fastly cache tags · Cloudflare cache rules

Invalidation strategies

"There are only two hard things in computer science: cache invalidation and naming things." The strategies that work on mobile:

  • Pure TTL - expire by clock; simplest, always-correct asymptotically. Wrong when content changes faster than the TTL.
  • Versioned URLs - style.css?v=8f3a2c1 or style.8f3a2c1.css. The query string or filename hash is the content hash; any change to the asset changes the URL, which is a fresh cache key. Used universally for static asset delivery. Cache-Control: max-age=31536000, immutable is safe because the URL itself changes when the content does.
  • Tag-based purge - emit Cache-Tag on response, purge by tag on mutation. The mobile app doesn't see this directly - it benefits from cache hits that the CDN refreshes the moment the origin's data changes.
  • Manual purge - explicit PURGE API call by URL. Used for one-off invalidations (incident response, deployed-bad-content rollback). Slow and high-blast-radius compared to tag purges.

The right blend on a real app is usually all four: short TTL on user-personalized API responses (60 s), aggressive max-age=1y, immutable on hashed static assets, tag-purge on the origin side for mutation flows, manual purge as the break-glass for incidents.

Cache-busting hash in the filename

The mobile-relevant variant: in-app assets shipped via the App Bundle / on-demand modules can carry a content hash in their filename. Any code change changes the hash, the asset URL, and therefore the cache key - the previous version's cached copy is harmless because nothing references it. This is exactly the pattern web bundlers use, transplanted to mobile asset delivery.

Mobile-specific concerns

The same RFC 9111 semantics apply, but mobile has shape-specific concerns that desktop and server-side don't.

Offline reads from cache: a phone in a tunnel still has its disk cache. The right pattern is stale-if-error=86400 on Cache-Control (or cacheControl(CacheControl.FORCE_CACHE) on OkHttp when the app detects no network). The UI marks the rendered content as "offline copy from N minutes ago" so the user knows what they're looking at.

Cache + network race for "freshest data we have, fast": fire the cache read and the network request concurrently; show the cache value immediately, then update the UI when the network response arrives. This is stale-while-revalidate enforced by the client rather than by Cache-Control. Useful for endpoints where the server hasn't been annotated and you can't change it.

suspend fun loadFeed(): Flow<Feed> = flow {
    httpCache.read(feedKey)?.let { emit(it) }      // immediate stale read
    val fresh = api.getFeed()                       // network refresh
    httpCache.write(feedKey, fresh)
    emit(fresh)                                     // UI updates on fresh
}

Image cache sizing on low-RAM devices: 1 GB-RAM devices still ship; the 1/8-of-heap rule yields ~12 MB on these. A two-pass-decoded 1080 px JPEG at ARGB_8888 is ~8 MB - one image is most of the cache. Either use RGB_565 (halves decoded RAM, no alpha) for opaque thumbnails or downsample more aggressively. onTrimMemory(TRIM_MEMORY_RUNNING_CRITICAL) is the signal to flush the in-memory cache to disk.

Connection-aware caching: on metered or weak connections (ConnectivityManager.getNetworkCapabilities().hasCapability(NET_CAPABILITY_NOT_METERED)), bias every read toward cache, only revalidating on user-initiated refresh. The HTTP cache supports this directly: cacheControl(CacheControl.Builder().maxStale(1, TimeUnit.HOURS).build()) accepts up to 1 hour of staleness without revalidation.

See also

  • Network Protocols - the HTTP version determines what cache semantics work; HTTP/2 multiplexing changes the math on connection-pool vs cache wins.
  • Image Loading Internals - the canonical mobile cache pipeline (active resources -> memory LRU -> disk LRU -> decoded vs encoded storage).
  • Persistence - Room as the durable source of truth for slow-changing config and offline data, not a live-data cache in front of the network; its freshness questions are about sync, not TTL.

Used in

  • Photo Gallery App - thumbnail cache sizing and the encoded-vs-decoded tradeoff drive the gallery's scroll behavior; the upload path uses If-Match for optimistic concurrency.
  • Newsfeed App - SWR on the paginated feed read, tag-based CDN purge on post mutation, and the cache + network race pattern for "freshest data we have."
  • Messenger App - the message list is a local-first cache; remote reads patch into it rather than replace, and read receipts ride the cache invalidation path.
  • Image Loader SDK - the dedicated problem on this material. Implements the four-tier cache (active / memory / disk / network) end-to-end with the OOM-safe decode rules.
  • Networking Client SDK - the OkHttp Cache integration and the interceptor-injected Cache-Control patch pattern are deep-dives there.

Done reading? Mark it so it sticks in your dashboard.