Mobile system design · 12 min read

Mobile caching system design interview: the image loader

How to design caching in a mobile system design interview: memory and disk tiers, eviction, invalidation, HTTP caching and the image loader prompt.

This guide is for mobile engineers facing a mobile caching system design interview, a round where caching is the prompt (“design an image loader”) or the deep dive the interviewer picks (“how does your feed cache work?”). It covers the tiers of a mobile cache, how eviction works under memory pressure, how to choose an invalidation strategy, what HTTP gives you for free, a worked image loader design, and the questions the round asks with the answers that score.

The idea that changes the whole answer: the cache on the phone is not there to make things fast. It is a copy of server state that lags behind the server and is sometimes cut off from it entirely. Once you say that, staleness stops being a bug you forgot to fix and becomes a budget you set on purpose, per screen.

The prompt: design an image loader

The image loader is a common caching prompt on mobile because it touches every tier at once. The prompt usually sounds like: “Cells in a scrolling feed request images by URL. Do not download the same URL twice, and do not waste memory.”

The clarifying questions that change the design:

  • Memory only, or disk too? Disk means images survive a relaunch, and it adds eviction and corruption questions.
  • How many distinct images, and how large? A feed of thumbnails and a photo viewer of full-resolution images need different budgets.
  • Two cells request one URL at once. Does the second start its own download, or join the one in flight? (Join it.)
  • A cell scrolls away mid-load. Should the request be cancelled?
  • Do the images change at the same URL? If an avatar URL is reused for a new photo, the cache needs a freshness rule. If URLs are content-addressed, it does not.

That last question is rarely asked, and it decides the invalidation half of the design.

Memory, disk, network

A strong answer names the tiers and what each one is allowed to get wrong.

Tier What lives there Lifetime What it may get wrong
In-memory Decoded images ready to draw, view models Dies with the process Almost nothing; it is the freshest
On disk Encoded image files, cached API responses, the local database Survives relaunches Staleness lives longest here
HTTP cache Responses stored by the URL loading system according to server headers Governed by Cache-Control and validators Whatever the server’s headers allow
Edge and server caches CDN, a backend-for-frontend cache Not yours Can hand you a stale value before you cache it

Two platform facts that belong in the answer:

  • The operating system can delete your caches directory. On iOS, files in Library/Caches may be purged when the device is low on space. On Android, files in the app’s cache directory may be removed by the system when storage is low. So the disk cache is for things you can re-download, never for the only copy of user data. The user’s unsynced work belongs in the database, not in a cache.
  • Memory is budgeted in decoded pixels, not file size. A 12-megapixel photo, 4000 by 3000 pixels, decodes to about 48 MB at 4 bytes per pixel, whatever its file size on disk. A memory tier that counts items, or counts file bytes, will be wrong by a large factor on photos. Budget by decoded size, and downsample to the size the view displays before caching.

Both facts come back later in the round. The first decides what the disk tier may hold; the second decides how the memory tier is sized.

Eviction under memory pressure

The interviewer will ask what happens when memory runs short.

  • iOS: NSCache evicts automatically under memory pressure and is thread-safe. Its countLimit and totalCostLimit are limits the cache tries to respect, not strict guarantees. Set the cost to the decoded byte size, not to 1 per image.
  • Android: LruCache with sizeOf returning the bitmap’s byte count gives a byte-bounded least-recently-used cache. Respond to memory trim callbacks by shrinking or clearing it.
  • Both: clear or shrink the memory tier when the system warns, and never hold full-size images for thumbnails.

The follow-up is often “implement the LRU yourself”: a hash map for constant-time lookup and a doubly linked list ordered by recency, where each node keeps its key, so evicting the tail can also delete it from the map. Then the concurrency question: an actor gives compile-checked safety but makes every access an await; a lock keeps a synchronous API for a hot path at the cost of manual discipline. Say when each applies.

Invalidation and freshness

The general problem: you only learn a cached value is stale by asking the server, and not asking the server is the reason the cache exists. On a phone it is harder: the device is often offline, and its clock cannot be trusted. So do not promise the cache is never stale. Set a staleness budget per screen and decide what the user sees when it is exceeded.

Data Staleness budget Strategy
Avatars, display names Hours or a day Time-to-live, refresh lazily
Feeds, counts Seconds to minutes Refresh on foreground, show “updated just now”
Anything the user acts on: a balance, stock, whether a message still exists Zero for the decision Fetch fresh before the action, or make the action safe to be wrong on the server

The strategies, and when each is right:

  • Time-to-live. Cheap and blind: it knows the clock, not whether the value changed. Right for data that changes rarely and tolerates some staleness.
  • Explicit invalidation on your own writes. Surgical, and blind to writes made by another device or another user.
  • Event-driven invalidation. A push or a change feed tells the client what changed. It is the only one that hears about a change when it happens, and it hears nothing while the device is offline.
  • Optimistic updates for your own writes. The user sees their change immediately. That is read-your-writes, the most valuable guarantee on mobile, and it needs a durable pending write and a reconciliation path if the server rejects it.

A second trap is about who may write the cache. A push arrives carrying an older title while the user’s rename is still pending, writes the list row directly so the notification tap feels fast, and the user watches the new title flip back to the old one. That is not a rendering bug. Two code paths own the same row. Label every copy of a value: authoritative (the server record), replicated (the local database mirror), cached (a copy with an expiry rule), derived (recomputed, never stored as a source) or ephemeral (screen state). Then send every write, whether it comes from a response, a push or a background refresh, through one repository that knows the pending local edits. The push becomes a proposal, not a write.

One trap interviewers like: every client refreshing on the same fixed timer. When the backend recovers from an outage, every cache that expired during it stampedes the server at once. Add jitter to expiry and refresh, the same fix as for retries.

HTTP caching you get for free

Before building a custom response cache, say what the platform already does. URLSession on iOS uses URLCache, and OkHttp on Android has an HTTP cache you enable with a size and a directory. Both are driven by the server’s headers:

  • Cache-Control: max-age says how long a response is fresh without asking.
  • An ETag lets the client revalidate: it sends If-None-Match, and the server answers 304 Not Modified with no body if nothing changed. That costs a round trip and saves the payload.
  • no-store means do not cache at all, which is what you want for personal or sensitive responses.

The senior point is the contract: caching behaviour lives partly in the server’s headers, so it is negotiated with backend, not decided in the client alone. For images at content-addressed URLs, the server can mark them cacheable for a very long time, and invalidation disappears from the design.

Worked example: the image loader, designed

Putting it together for the feed prompt:

  1. Lookup order. Memory, then disk, then network. A memory hit returns synchronously for the cell.
  2. In-flight deduplication. A dictionary of running downloads keyed by URL. A second request for the same URL awaits the first one’s task instead of starting another. On iOS an actor makes the check-then-insert safe without a lock; the download runs outside the actor so other URLs proceed.
  3. Decode off the main thread, downsample to the display size. On iOS with ImageIO thumbnails; on Android with sampled decoding. Cache the downsampled result, costed by its byte size.
  4. Disk tier stores encoded bytes, bounded by total size, evicted least recently used, and treated as disposable.
  5. Cancellation. Because the download is shared, one cell scrolling away should not cancel it for another cell still waiting. The refinement is to count waiters and cancel only when the last one leaves. The cell itself checks, when the image arrives, that it still wants this URL, because a reused cell may have moved on.
  6. Priorities. Visible cells before prefetch.
  7. Measuring it. Memory hit rate, disk hit rate, bytes downloaded per session, decode time on the main thread (should be zero), and memory warnings per session.

The follow-ups this design tends to get: “What is your eviction rule?”, “Swap the actor for a class holding a dictionary: what breaks?” (a data race, which Swift 6 strict concurrency refuses to compile), “Two cells show the same URL”, and “Why not use the system’s async image view?” (no cross-view deduplication and little cache control).

Questions this round asks, and the answers that score

Question Answer that scores The trap
“How big is your memory cache?” A byte budget based on decoded size, and it shrinks on memory warnings “200 images”
“Where do cached files live?” The caches directory, knowing the system may purge it; user data never lives there Putting unsynced user work in a cache
“Two cells request the same URL.” One download, the second request awaits the in-flight task Two downloads, or a lock held across the network call
“The image at a URL changed.” Freshness rule from headers or a versioned URL; ideally content-addressed URLs “Clear the cache on every launch”
“The user sees a deleted message from cache.” Staleness budget per screen; data the user acts on is fetched fresh or validated on the server Treating all cached data the same
“A push payload updates the list row directly.” One write path that reconciles against pending local edits; the push is a proposal Letting any payload write state outside that path
“Why not cache everything forever?” Disk budget, privacy of cached personal data, and stale data the user acts on “Storage is cheap”
“The backend falls over right after it recovers.” Synchronised refresh is a thundering herd; jitter expiry and retries More servers
“How do you know the cache works?” Hit rates per tier, bytes per session, main-thread decode time, memory warnings “It feels faster”

Use the table as a drill: cover the middle column, answer each question out loud, then check what you left out. The trap column is what the interviewer is listening for.

Sources

What to do next

Free guide: the Phoenix Method