Caching Layers, Read Policies, and Freshness Guarantees

Capítulo 5

Estimated reading time: 13 minutes

+ Exercise

Why caching layers matter in offline-first apps

In an offline-first mobile app, “cache” is not a single thing. It is a set of layers that cooperate to deliver fast reads, tolerate disconnections, and still provide credible freshness guarantees when the network returns. A good caching design answers three questions for every read: (1) Where do we read from first? (2) When do we refresh? (3) How do we communicate freshness to the user and to downstream logic?

Unlike a purely online app, you cannot assume that a network call is available or timely. Unlike a purely local app, you cannot assume local data is authoritative. The result is a need for explicit read policies (how to choose sources) and explicit freshness semantics (what “up to date” means, how long it is acceptable to be stale, and how to detect/repair staleness).

Common caching layers and what each is good at

1) In-memory cache (process memory)

This is the fastest layer and usually the most volatile. It is ideal for: hot objects, repeated reads during a session, and computed/derived views. It is not a source of truth. In mobile apps it is also fragile: the OS can kill your process at any time, and memory pressure can evict data.

  • Pros: ultra-fast reads, easy to invalidate, great for UI responsiveness.
  • Cons: disappears on restart, can become inconsistent with disk if not coordinated.

2) On-disk local store (database / file-backed cache)

This is the durable offline layer. Reads are slower than memory but still far faster than network and available offline. For caching discussions, treat the local store as a cache of server state plus a log of local changes (even if your implementation combines them). Freshness is not automatic: disk data can be old, and you need metadata to reason about it.

  • Pros: survives restarts, supports offline, can store metadata for freshness.
  • Cons: needs schema/versioning, requires careful invalidation and conflict-aware refresh.

3) Network cache (HTTP cache / CDN / proxy caches)

Even when you “hit the network,” you might not hit the origin server. HTTP caching (ETag, Last-Modified, Cache-Control) can reduce bandwidth and improve perceived freshness. For mobile offline-first, HTTP caching is useful but rarely sufficient by itself because your app still needs offline reads and domain-specific merging.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

Download the app

  • Pros: efficient revalidation (304 Not Modified), reduces payloads.
  • Cons: limited to HTTP semantics; doesn’t solve local mutation or complex queries.

4) Server-side caches (application cache, query cache)

These are not directly controlled by the client, but they affect freshness and consistency. If the server serves cached responses, the client may see “fresh” according to HTTP but still lag behind recent writes. Your client-side freshness guarantees should be defined relative to what the server commits and exposes, not relative to internal server caching behavior.

Cache keys, entities, and query results

Caching is easiest when you cache by entity ID (e.g., User:123). Many app screens, however, are driven by queries: “inbox messages sorted by time,” “tasks due today,” “products in category X with filters.” Query-result caching introduces two extra challenges: (1) invalidation is harder, and (2) freshness depends on both the membership of the result set and the freshness of each item inside it.

A practical approach is to separate:

  • Entity cache: store canonical entities by ID with per-entity metadata.
  • Query index cache: store lists of IDs for a query plus query-level metadata (when fetched, what cursor/version, what filters).

The UI reads the query index to get IDs, then reads entities by ID. This makes partial refresh possible: you can refresh the index without refetching every entity, or refresh specific entities whose freshness is suspect.

Read policies: choosing where data comes from

A read policy is a deterministic rule for selecting sources and timing refresh. You should define read policies per use case, not globally. A “profile screen” might tolerate stale data for minutes, while a “payment status” screen might require near-real-time confirmation.

Policy: Cache-first

Behavior: read from local (memory/disk). If present, return immediately. Optionally refresh in background depending on staleness.

When to use: most list/detail screens where fast rendering matters and slight staleness is acceptable.

Risk: without a staleness check, data can remain old indefinitely.

Policy: Network-first (with cache fallback)

Behavior: attempt network. If it fails or times out, return cached data.

When to use: screens where freshness is critical but offline fallback is still needed (e.g., inventory availability, time-sensitive status).

Risk: can feel slow if network is flaky; needs timeouts and a “show cached now, update later” variant for good UX.

Policy: Stale-while-revalidate (SWR)

Behavior: return cached data immediately (even if stale), then trigger a refresh; when refresh completes, update cache and notify UI.

When to use: feeds, dashboards, most content browsing. SWR gives fast first paint and eventual freshness.

Risk: requires UI to handle updates after initial render; must avoid disruptive jumps (e.g., list reordering) without user-friendly diffing.

Policy: Cache-only

Behavior: read only from cache; never triggers network.

When to use: explicitly offline modes, airplane mode screens, or when network is disallowed (roaming restrictions, user setting).

Policy: Network-only

Behavior: always fetch from network; may still write through to cache.

When to use: rare in offline-first, but useful for actions that must reflect server truth (e.g., “verify coupon,” “check session validity”) or for debugging/admin tools.

Freshness guarantees: defining what “fresh” means

Freshness is a contract between your data layer and the rest of the app. Without a contract, you end up with ad-hoc refresh calls and inconsistent UX. A strong contract has three parts:

  • Freshness metric: time-based (TTL), version-based (ETag/revision), or event-based (subscription/stream position).
  • Guarantee level: best-effort, bounded staleness (e.g., “no older than 5 minutes”), or validated (e.g., “confirmed with server at time T”).
  • Exposure: how the UI and business logic can observe freshness (metadata, states, badges).

Time-based freshness (TTL)

Each cached record (or query result) has a timestamp fetchedAt. A TTL defines how long it is considered fresh. This is simple and works well when the server does not provide versions or when changes are not frequent.

However, TTL is a heuristic: data might change right after you fetched it. TTL gives a bounded staleness guarantee only if you also have a background refresh mechanism that runs at least as often as the TTL when connectivity allows.

Version-based freshness (ETag, revision, server version)

Here the cache stores a version token from the server. On refresh, you revalidate: “Has this changed since version X?” If not, you can keep the cached body and just update metadata. This can provide a stronger guarantee than TTL with less bandwidth.

For entity caches, store serverVersion per entity. For query caches, store a queryVersion or cursor token if the API supports it. If the server supports conditional requests, you can implement “validate without downloading.”

Event-based freshness (streams, subscriptions, change feeds)

If your backend can provide a change feed (e.g., “changes since token”), you can treat the cache as fresh up to a known stream position. This can enable a strong guarantee: “cache includes all changes up to token N.” It also reduces the need for full refetches.

Event-based freshness requires careful token persistence and replay handling, but it is powerful for collaborative or frequently changing data.

Freshness metadata you should store

To make read policies and guarantees implementable, store explicit metadata alongside cached data. Typical fields:

  • fetchedAt: when the data was last fetched from the server.
  • validatedAt: when the data was last revalidated (may be later than fetchedAt if 304).
  • expiresAt: computed from TTL or server cache headers.
  • etag / lastModified / serverVersion: version token for conditional requests.
  • source: where the current value came from (cache, network, merged).
  • staleReason: TTL expired, missing validation, partial data, etc.

For query caches, store metadata per query key (filters + sort + pagination): queryKey, fetchedAt, cursor, complete (whether you have all pages), and ids list.

Step-by-step: implementing stale-while-revalidate for a list screen

This pattern is a workhorse for offline-first UX. The goal is: show cached content immediately, then refresh in the background, and update the UI if new data arrives.

Step 1: Define a query key and cache structures

Create a stable key for the list query, including filters and sort. Example: tasks?status=open&sort=dueDate. Store:

  • Query index: { queryKey, ids[], fetchedAt, expiresAt, etag }
  • Entities: { id, fields..., fetchedAt, etag }

Step 2: Read from cache and emit immediately

Load the query index by key. If present, load entities by IDs and render. Also compute a freshnessState for the UI (fresh, stale, unknown).

// Pseudocode (platform-agnostic) for SWR read of a query list
function observeTasksList(queryKey, ttlMs): Stream<Result> {
  return stream {
    const index = db.queryIndex.get(queryKey)
    if (index != null) {
      const tasks = db.tasks.getMany(index.ids)
      emit({ data: tasks, freshness: freshnessFrom(index), source: 'cache' })
    } else {
      emit({ data: [], freshness: 'missing', source: 'cache' })
    }

    // Trigger refresh if missing or stale
    if (index == null || now() > index.expiresAt) {
      refreshTasksList(queryKey, index?.etag)
        .then(updated => {
          emit({ data: updated.tasks, freshness: 'fresh', source: 'network' })
        })
        .catch(err => {
          // Keep cached view; optionally emit an error state
          emit({ data: index ? db.tasks.getMany(index.ids) : [], freshness: index ? 'stale' : 'missing', error: err })
        })
    }
  }
}

Step 3: Refresh with conditional requests

If you have an ETag for the query response, send it. If the server returns 304, update validatedAt/expiresAt without rewriting entities. If it returns 200, upsert entities and replace the query index IDs.

async function refreshTasksList(queryKey, etag) {
  const res = await http.get('/tasks', { headers: etag ? { 'If-None-Match': etag } : {} })
  if (res.status == 304) {
    db.queryIndex.update(queryKey, { validatedAt: now(), expiresAt: now() + TTL })
    return { tasks: db.tasks.getMany(db.queryIndex.get(queryKey).ids) }
  }
  const body = res.json
  db.transaction(() => {
    db.tasks.upsertMany(body.items.map(item => ({ ...item, fetchedAt: now(), etag: item.etag })))
    db.queryIndex.put({ queryKey, ids: body.items.map(i => i.id), fetchedAt: now(), expiresAt: now() + TTL, etag: res.headers.etag })
  })
  return { tasks: body.items }
}

Step 4: Prevent UI thrash on update

When the refreshed list arrives, it may reorder items or insert/remove entries. Use stable keys and diffing in the UI layer. If reordering is disruptive, consider: (1) animating changes, (2) applying updates only when the user is at top of list, or (3) showing a “New items” affordance that the user taps to refresh the view.

Step-by-step: per-entity freshness for detail screens

Detail screens often need stronger guarantees than list screens because users may act on the data (edit, share, rely on status). A practical approach is to treat the entity as fresh if it has been validated recently or if its server version matches.

Step 1: Read entity from cache and show immediately

Render cached entity with a visible freshness indicator if it is stale (e.g., “Updated 2 hours ago” or a subtle “Offline data” label). The UI should not block on network unless the action requires it.

Step 2: Decide refresh threshold based on field sensitivity

Not all fields have the same freshness needs. For example, a “shipping status” field may need a 30-second TTL, while “recipient address” can tolerate hours. You can implement field-sensitive TTL by storing a single entity but computing staleness based on the most sensitive field used on that screen.

Step 3: Revalidate with ETag or version

Send If-None-Match with the entity ETag. If 304, update validatedAt. If 200, replace entity fields and update metadata.

Write-through, write-back, and cache coherence considerations

Even though this chapter focuses on reads, your caching layers must remain coherent when local changes occur. Two practical rules help:

  • Write-through for local store: when the user changes something, update the local store immediately so reads reflect the new state. This keeps the UI consistent.
  • Invalidate or patch related query caches: if an entity changes, any cached query indexes that include it may need updates (e.g., resorting, membership changes). Prefer targeted patching when possible; otherwise mark affected queries as stale so they refresh soon.

For example, if a task’s status changes from open to done, the “open tasks” query index should remove its ID, and the “done tasks” index should add it (or both should be marked stale). If you do nothing, the entity cache is correct but the list screen is wrong.

Freshness in the UI: making guarantees visible and actionable

Freshness guarantees are only useful if the UI and business logic can react to them. Common patterns:

  • Freshness badges: “Offline,” “Last updated at 10:42,” or “May be outdated.” Keep it subtle but clear.
  • Optimistic rendering with background refresh: show cached, then update quietly; if the update changes critical fields, highlight the change.
  • User-triggered refresh: pull-to-refresh should force a network attempt and update validatedAt even if the body is unchanged.
  • Blocking gates for critical actions: before irreversible actions (e.g., submit payment, confirm booking), require a “validated within X seconds” guarantee; if not met, prompt to reconnect or retry.

Expose freshness as a first-class part of your data result type, not as an afterthought. For example, return { data, freshness, lastValidatedAt, error } so screens can make consistent decisions.

Preventing cache stampedes and redundant refreshes

Mobile apps often have multiple subscribers to the same data (multiple screens, widgets, background refresh). Without coordination, they can trigger duplicate network calls and drain battery.

Single-flight requests

Implement a “single-flight” mechanism: if a refresh for a given key is in progress, additional callers await the same promise/future instead of starting new requests.

const inFlight = new Map() // key -> Promise

function refreshOnce(key, fn) {
  if (inFlight.has(key)) return inFlight.get(key)
  const p = fn().finally(() => inFlight.delete(key))
  inFlight.set(key, p)
  return p
}

Backoff and jitter

If refresh fails, don’t immediately retry in a tight loop. Apply exponential backoff with jitter. This matters for freshness guarantees: you can still say “best-effort bounded by connectivity,” but you avoid making the situation worse under poor networks.

Dealing with partial data and “fresh but incomplete” states

Sometimes data is fresh but incomplete: you fetched only the first page, or you have a summary object but not full details. Treat completeness as separate from freshness.

  • Completeness metadata: store flags like hasDetails, pagesLoaded, complete.
  • Read policy integration: a detail screen can accept a fresh summary but still trigger a fetch for missing fields.

This avoids a common bug: a UI thinks it is “fresh” because fetchedAt is recent, but the user sees placeholders because required fields were never cached.

Choosing TTLs and guarantees per feature

TTL selection is a product and engineering decision that should be encoded in code, not left implicit. A practical method is to define freshness classes:

  • Critical: must be validated within seconds (e.g., security/session, transactional status).
  • Interactive: should be within minutes (e.g., team comments, assignments).
  • Reference: hours/days acceptable (e.g., help content, static catalogs).

Then map each repository method or query key to a class. This makes read policies consistent and testable: you can write tests that assert “this call triggers refresh when validatedAt is older than X.”

Testing freshness and read policies

Because caching behavior is time-dependent, tests should control time. Use a fake clock and deterministic network stubs.

  • Unit tests: given cached data with expiresAt in the future, verify cache-first returns without network; given expired data, verify SWR emits cached then triggers refresh.
  • Integration tests: simulate 304 responses and verify validatedAt updates without rewriting entities; simulate network failure and verify fallback behavior.
  • UI tests: verify stale indicators appear when expected and disappear after refresh.

Now answer the exercise about the content:

In a stale-while-revalidate read policy for an offline-first list screen, what should happen when cached data exists but is stale?

You are right! Congratulations, now go to the next page

You missed! Try again.

Stale-while-revalidate returns cached data right away for fast UI, then triggers a refresh in the background and updates the cache and UI when the refresh finishes.

Next chapter

Designing a Sync Engine Architecture

Arrow Right Icon
Free Ebook cover Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms
26%

Offline-First Mobile Apps: Sync, Storage, and Resilient UX Across Platforms

New course

19 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.