Every request asks for one of 24 items (a Zipf-weighted popularity distribution — a few items are requested far more than the rest, like real app content). It is served by the first tier that has a usable copy:
for tier in [L1 memory, L2 disk, Network]:
entry = tier.get(key)
if entry exists:
age = now - entry.cachedAt
if age < entry.ttl: # fresh
return HIT(tier)
elif SWR_enabled: # stale but usable
schedule background fetch from Network
return STALE_HIT(tier) # served instantly, no wait
# else: treated as absent, fall through
# not found here -> try next, slower tier
L1 (memory) holds 6 items with a short TTL and LRU eviction; L2 (disk) holds 16 items with 4× the TTL. A full miss pays the network's round-trip latency (WiFi/LTE/3G, each with its own jitter) and then populates both L1 and L2 with a fresh TTL — this is why hit rate climbs after a cold start as the working set gets pulled into cache.
- Stale-While-Revalidate — when an entry has expired but SWR is on, the stale value is still returned immediately (fast, no user-visible wait) while a background request quietly refreshes it. Turn it off to see every expiry become a synchronous, slow network round-trip instead — the classic naive TTL-cache behavior.
- TTL slider — shorter TTL means data is fresher but more requests fall through to disk/network; longer TTL means higher hit rate but staler data.
- Request rate — how many requests per second the simulated app issues; higher rate means more chances to reuse a warm cache before entries expire.
- Network profile — sets the round-trip latency (and its jitter) that a full miss or a background revalidation must pay.
This is the exact shape of the caching stack behind libraries like SDWebImage, Glide, and most mobile HTTP clients: a fast small memory tier, a larger slower disk tier, TTL-based expiry, and stale-while-revalidate to hide network latency from the user.