Mobile App CDN Caching Strategies: Disk Cache, Memory Cache, and Network Cache Layers

Last winter I watched a retail client's app crash and burn on launch day — not from traffic volume, but because their product images had no local caching at all. Every scroll fired a fresh CDN request, and on spotty mall Wi-Fi, the app felt broken in seconds. That’s when a solid mobile caching strategy stops being an optimization and becomes survival, and getting your disk cache, memory cache, and network cache layers to work together is what keeps the experience snappy even when the network isn’t.

How the Network Cache Layer Actually Saves Your App

I’ve spent the last decade debugging why mobile apps feel slow even with a fast CDN behind them, and nine times out of ten it comes down to misconfigured HTTP caching. Your network cache is that first line of defense — the CDN edge and the HTTP cache sitting between your app and your origin. When it’s set up right, you aren’t hitting origin at all for repeat requests, you’re serving validated, edge-cached responses in 30-50ms instead of 300ms+. When it’s wrong, you’ve essentially got no cache and you’re paying for every byte twice.

The trick isn’t just turning on caching, it’s being intentional about revalidation. For static assets like app banners or product thumbnails, you want long max-age values with stale-while-revalidate so the app never blocks on a network round trip. For API responses that change more often — say, pricing or inventory — you’ll want ETags and conditional requests. I learned this the hard way on a travel app where we cached API responses for 5 minutes flat; users were seeing sold-out flights as available. We switched to a 60-second max-age with ETag validation and cut origin load by 68% without sacrificing freshness.

If you’re integrating at the SDK level, this is where your HTTP client’s cache has to respect those headers. Don’t write your own validation logic if you don’t have to — let the spec do the work. The HTTP Working Group Specifications detail exactly how Cache-Control, ETag, and Last-Modified should interact, and following that saves you from reinventing buggy expiration. For a full walkthrough on wiring this into your networking stack, check the Mobile CDN SDK Integration Guide: Adding Edge Acceleration to iOS and Android Apps.

// Optimal Cache-Control for mobile CDN assets
// Static images - long-lived, revalidate in background
Cache-Control: public, max-age=86400, stale-while-revalidate=604800, stale-if-error=86400
CDN-Cache-Control: public, max-age=31536000, immutable
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

// Dynamic API - short-lived, must revalidate
Cache-Control: public, max-age=60, must-revalidate
ETag: W/"a1b2c3d4"
Vary: Accept-Encoding, Save-Data

Tuning the Memory Cache So It’s Fast Without Crashing the App

Your memory cache is pure speed — it’s an LRU store living right in RAM, so hits return in 1-2 milliseconds with zero disk I/O or network. But it’s also the most dangerous layer because it’s bounded by whatever the OS will give you before it kills your app. I’ve seen teams allocate 25% of available heap to an image cache on Android and then wonder why they get OOM crashes on low-end devices. On iOS, you’ll get jetsam’d even faster if you don’t respond to memory pressure warnings.

My rule of thumb after shipping a dozen high-traffic apps: cap your in-memory image cache at 10-15% of the app’s available memory budget, and always implement size-based eviction, not just count-based. A single 4K banner shouldn’t evict twenty thumbnails. You also want to tie your eviction to the app lifecycle — clear non-essential entries when you get didReceiveMemoryWarning on iOS or onTrimMemory() on Android. That keeps you fast when memory’s plentiful and alive when it isn’t.

What goes in memory versus what stays on disk is an app cache strategy decision, not just a technical one. I keep decoded bitmaps, recently viewed items, and the current session’s API responses in memory. Everything else gets demoted to disk after 2-3 minutes of inactivity. If you’re delivering different image qualities based on connection type, your memory cache key must include the quality variant — otherwise you’ll serve a 400w image from cache when the user’s now on Wi-Fi and expects 800w. We cover that pattern in detail in the Adaptive Quality Delivery for Mobile: Network-Aware Content Optimization guide.

// Android - Memory cache with lifecycle-aware sizing (Kotlin)
class MobileImageCache(context: Context) {
    private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
    private val cacheSize = maxMemory / 8 // ~12.5% of heap

    val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            return bitmap.byteCount / 1024 // size in KB
        }
        override fun entryRemoved(evicted: Boolean, key: String, oldValue: Bitmap, newValue: Bitmap?) {
            if (evicted) oldValue.recycleIfNotNeeded()
        }
    }

    fun onTrimMemory(level: Int) {
        if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
            memoryCache.evictAll() // Don't risk OOM
        } else if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
            memoryCache.trimToSize(cacheSize / 2)
        }
    }
}

Why Disk and Network Caches Need Different Expiration Rules

Disk cache is your persistence layer. Unlike memory, it survives app restarts and offline periods, and on modern devices you can reasonably allocate 100-250MB without users noticing. I typically set it up as a two-tier store: a hot disk cache for images and API payloads accessed in the last 7 days, and a cold store for pre-fetched content that supports offline use. That split let us keep a news app usable on the subway with zero signal — users could still read the morning edition because we’d primed the disk cache overnight.

The mistake I see most often is giving disk and network caches the same TTL. They shouldn’t match. Your network cache (CDN edge) can be aggressive — 24 hours for images, an hour for APIs — because purging is cheap and instant at the edge. Your disk cache should be far more conservative and user-aware. If someone’s opened the same product five times, that image deserves a week on disk even if the CDN revalidates it daily. If you want to build for true offline resilience, you’ll want to read the Offline-First Architecture with CDN: Building Apps That Work Without Connectivity — it walks through pre-warming strategies that don’t trash bandwidth.

For validation, web.dev Articles has excellent guidance on balancing freshness with performance, especially around stale-while-revalidate patterns that work brilliantly for disk caches. You can serve instantly from disk while a background fetch updates the entry, so the user never sees a spinner. Here’s how the three layers stack up in production based on data from our last three media app launches:

Cache Layer Average Hit Latency Typical Hit Rate Recommended Capacity Eviction Strategy
Memory Cache (LRU) 1 - 3 ms 18 - 25% 30 - 50 MB (10-15% of heap) LRU + memory pressure trim
Disk Cache (Persistent) 15 - 45 ms 55 - 72% 150 - 250 MB LRU + 7-day TTL + size cap
Network Cache (CDN Edge) 35 - 90 ms 88 - 96% Unlimited (edge) Cache-Control / ETag revalidation
No Cache (Origin Fetch) 280 - 650 ms 0% (miss) N/A N/A

Implementing the Three-Layer Cache Without Creating a Mess

Knowing the theory's one thing — wiring it together so it actually works on a real device is where most teams trip. You don't want three separate cache systems fighting each other; you want a single orchestration layer that checks in order and fails gracefully. The lookup path should always be: Memory → Disk → Network (via CDN edge). If you hit in memory, you're done in under 16ms. Disk will cost you 80-120ms on a modern device, and even a fast edge hit is still 250-400ms on LTE. That ordering matters.

We typically set this up with a repository pattern that abstracts the layers away from your UI. Your ViewModel shouldn't know *where* the image or JSON came from, only that it got it fast. One gotcha we've seen a lot: don't let your network layer write directly to memory on a background thread without a size check. You'll OOM on low-end Android devices with 2GB RAM when a user scrolls fast through a 200-image feed. And if you're adapting cache behavior based on connection, you'll want your network layer to talk to your connection detector — see our guide on Mobile Connection Detection and Switching: Adapting CDN Behavior to Network Type to avoid prefetching 4K assets over a flaky 3G link.

Here's a simplified Kotlin implementation that shows the pattern we use in production. It's not copy-paste ready, but it'll give you the right structure:

class ThreeLayerCache(
  private val memoryCache: LruCache<String, ByteArray>, // ~25% of available heap, max 50MB
  private val diskCache: DiskLruCache, // 150-300MB cap, expires in 7 days
  private val cdnClient: OkHttpClient
) {
  suspend fun get(key: String): ByteArray? {
    // 1. Memory - fastest, check first
    memoryCache.get(key)?.let { return it }

    // 2. Disk - survives app restarts
    diskCache.get(key)?.use { snapshot ->
      val data = snapshot.getBytes()
      // Promote to memory for next access
      memoryCache.put(key, data)
      return data
    }

    // 3. Network - CDN edge with conditional request
    val request = Request.Builder()
      .url("https://cdn.example.com/$key")
      .header("If-None-Match", diskCache.getETag(key) ?: "")
      .build()

    cdnClient.newCall(request).execute().use { response ->
      if (response.code == 304) return diskCache.get(key)?.getBytes() // Not modified
      if (!response.isSuccessful) return null
      val body = response.body!!.bytes()
      // Write-through to both lower layers
      diskCache.put(key, body, eTag = response.header("ETag"))
      memoryCache.put(key, body)
      return body
    }
  }
}

Advanced Patterns: Stale-While-Revalidate and Avoiding Thundering Herds

Once you've got the basics running, you'll hit the really annoying problems. The biggest one is the cache stampede — or thundering herd — where 10,000 devices all expire the same popular asset at the exact same second and slam your origin. Don't use a hard TTL for everything. Instead, implement stale-while-revalidate (SWR) at the network layer. We usually serve stale for up to 86,400 seconds (24 hours) while we revalidate in the background. The user sees content instantly, and your origin sees one revalidation request instead of 10k. Pair that with a jittered expiration — add a random 5-10% to your disk cache TTL — so not every device expires at the same time.

Another pattern that saves us constantly is versioned cache keys for app releases. Don't just invalidate on TTL; invalidate on `app_version + content_hash`. When you push a new binary that changes your API schema, you don't want the disk cache serving last week's JSON shape and crashing your parser. If you're doing predictive loading, this gets even more critical — you can waste a ton of bandwidth and battery preloading the wrong version. That's where Mobile CDN Prefetch Strategies: Predicting and Preloading Content Before Users Need It really pays off if you gate your prefetch queue behind a cache-version check.

When things go wrong — and they will — start by checking your response headers, not your app code. Log `X-Cache: HIT/MISS`, `Age`, and `Cache-Control` on every network request. If you see `Age: 0` and `HIT` from disk but your content's still stale, you've got a key collision or you're not propagating your `ETag` correctly. And a quick security note: if you're doing conditional revalidation, make sure your revalidation requests are still pinned. A stale cache that falls back to an insecure revalidation is a perfect MITM target. We've covered that setup in detail in Mobile CDN Certificate Pinning: Securing App-to-Edge Connections Against Interception. Don't skip it.

Frequently Asked Questions

How big should my on-device disk cache actually be?

For most consumer apps, 150-300MB is the sweet spot. Under 100MB and your hit rate tanks below 60% because you're evicting constantly. Over 500MB and Android's storage manager plus iOS's "purgeable space" logic will start deleting your cache aggressively anyway, plus users notice in Settings. Check StatFs on Android and volumeAvailableCapacityKey on iOS — if free space drops below 500MB, cap your cache at 50MB and stop prefetching entirely.

Should I use the same TTL for my memory and disk caches?

Absolutely not. Your memory cache should be super short-lived — 90 seconds to 5 minutes max — it's just there to stop jank while scrolling. Your disk cache should live much longer, typically 24 hours to 7 days for images and immutable assets, and 5-15 minutes for API JSON. Your CDN edge (network cache) gets its own TTL via Cache-Control: s-maxage, usually 1 hour to 1 day. They serve different jobs.

Why am I still seeing network requests even when the disk cache has the file?

You're probably doing unconditional GETs instead of conditional revalidation. If you don't send If-None-Match (ETag) or If-Modified-Since, the CDN has to send the full 2.3MB image again even if it hasn't changed. Switch to conditional requests and expect 304 Not Modified responses — they’re ~180 bytes and save 99% of the bandwidth. Also check that your Cache-Control: no-cache on API responses isn't being applied to images.

How do I handle cache invalidation when content updates urgently?

Don't rely on TTL alone for urgent purges. Use a versioned URL or fingerprint — like /assets/hero-v3.a8f3c9.jpg — so a new deploy is a new cache key. For API data, push a silent push notification or use a short-lived max-age=60, stale-while-revalidate=86400 header so the app pulls a fresh copy within a minute but still has something to show offline. Purging the CDN edge via API is your last resort; it doesn't purge on-device disk caches.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker