Engineering

A read path that cannot ask for fresh data

A cold open and a deliberate refresh want different things from a cache. One read method forces the repository to guess what only the call site knows.

MobileByteSensei 10 min read
Two counters: what is on the shelf, and what is coming out of the oven
kotlinarchitecturedata

A repository exposes a method that returns data. Screens call it. Behind it is a cache, and the cache decides whether to serve what it has or go and fetch.

The screen knows something the repository does not: whether this is a first open or a deliberate refresh. With one method, there is nowhere to say it.

The case this post is about

Two requests that a repository with one method cannot tell apart:

stream(key)        serve what we have, refresh in the background if stale
streamFresh(key)   go and fetch; serve the cache only if the fetch fails

Opening a screen is the first. Pulling to refresh is the second. They are different intentions with different correct behaviour, and the call site is the only place that knows which one this is.

With a single stream, pull-to-refresh serves the cache instantly and the user watches their deliberate gesture produce no change — so the next fix adds a cache-busting flag, then a force parameter, then a timestamp comparison at the call site, and the policy ends up distributed across every screen.

This post follows those two signatures, because the repository cannot infer intent and the difference is not a parameter.

The symptom

Pull-to-refresh does nothing visible. The user drags, the spinner appears, data comes back instantly — the cached data, because the repository had a recent value and quite reasonably served it. The gesture that exists specifically to mean I do not trust what I am seeing was answered with exactly what they were already seeing.

Asking at the counter and being handed what was already on the shelf

The second shape is the opposite and it is the one that gets noticed first. To make refresh work, somebody shortens the cache window. Now every screen open goes to the network, the app is slow on a poor connection, and the cold start that used to be instant takes a second and a half. One gesture's correctness was bought with every navigation's latency.

The third is what happens when the two are tuned against each other. The cache window becomes a compromise value that satisfies neither case — long enough that refresh sometimes does nothing, short enough that navigation sometimes stalls. Nobody can state what the number means, because it is encoding two different requirements at once.

The fourth is the detail screen opened from a list. The list was loaded two minutes ago; the detail screen opens and shows a cached record from the same fetch. Then a background refresh lands and the value changes under the user's eyes, which is correct behaviour and reads as a glitch — because nothing told them the first thing they saw was provisional.

Why the obvious fix failed

Tune the cache window. The default response and it cannot work, because one number is being asked to express two different intentions. Every value is a compromise between "this navigation should be instant" and "this refresh should actually fetch", and those are not points on the same axis.

Expose a boolean parameter. Closer, and it has a quiet problem: a boolean at a call site says nothing about what it means. load(true) is unreadable, and the reader has to go and check whether true means fresh or cached. It also tends to acquire a default, and the default is the one everybody uses.

Clear the cache before refreshing. Works and is heavy-handed. It discards data that is perfectly good if the fetch fails, so a refresh on a bad connection leaves the user with an empty screen where they previously had slightly old content. The refresh gesture becomes risky to use.

Let the screen call the network directly for refresh. Solves it for that screen and reintroduces the bypass problem — now there are two read paths, which is one of the ways a cache and its callers drift apart. The fix for a read problem should not create a write-path problem.

The actual mechanism

Make the intention part of the call, by exposing two read paths rather than one with a flag.

stream(key)        serve what we have, refresh in the background if stale
streamFresh(key)   go and fetch; serve the cache only if the fetch fails

The point is not that these are two methods — it is that the call site is the only place where the intention exists, and a single method gives it nowhere to put it. A screen opening knows it is opening. A pull-to-refresh handler knows the user asked. The repository knows neither of those things and will be guessing forever, however well you tune it.

The counter that goes to the oven, because that is what was asked for

Two named methods also read correctly at the call site, which a boolean does not. Somebody reviewing a screen sees streamFresh in the refresh handler and stream in the initial load, and the intent is legible without looking anything up — the same information a flag carries, in a form that does not require a trip to the definition.

The sub-case that matters most is what the fresh path does when the fetch fails. The naive version propagates the error and the user loses their content for a network blip. The right behaviour is to serve the cache and say that is what happened: the request was for fresh data, fresh data was not available, here is what we have. That is a third outcome and it needs somewhere to live in the return type — which is the same argument as pushing meaning into a signature rather than into a comment.

The fallback served, and declared as a fallback rather than passed off

There is a related subtlety on the other side. The ordinary path serves cached content immediately and may refresh behind it, which means the screen will sometimes receive a second value shortly after the first. That is not a glitch and it should not look like one — the read path should be able to say this is cached and a refresh is in flight, so the screen can show it as provisional rather than having the value change silently.

The fix

stream        cached-first, background refresh, emits provisional then settled
streamFresh   fetch-first, falls back to cache on failure and says so
call sites    initial load uses stream; explicit refresh uses streamFresh
return type   carries whether this value is cached, refreshing, or settled

The return type is the whole design, so it is the thing to write first. A value that cannot say whether it is cached carries the ambiguity into every call site:

sealed interface Loaded<out T> {
    val value: T
    data class Cached<T>(override val value: T) : Loaded<T>      // provisional, refresh in flight
    data class Settled<T>(override val value: T) : Loaded<T>     // from origin, current
    data class Stale<T>(override val value: T, val error: Throwable) : Loaded<T>  // fetch failed
}

class ProfileRepository(private val api: ProfileApi, private val cache: ProfileCache) {

    /** Cached-first. Emits immediately if anything is cached, then the settled value. */
    fun stream(id: String): Flow<Loaded<Profile>> = flow {
        cache.get(id)?.let { emit(Loaded.Cached(it)) }
        runCatching { api.fetch(id) }
            .onSuccess { cache.put(id, it); emit(Loaded.Settled(it)) }
            .onFailure { e -> cache.get(id)?.let { emit(Loaded.Stale(it, e)) } ?: throw e }
    }

    /** Fetch-first. Falls back to cache ON FAILURE and says so — never silently. */
    fun streamFresh(id: String): Flow<Loaded<Profile>> = flow {
        runCatching { api.fetch(id) }
            .onSuccess { cache.put(id, it); emit(Loaded.Settled(it)) }
            .onFailure { e -> cache.get(id)?.let { emit(Loaded.Stale(it, e)) } ?: throw e }
    }
}

The two differ by exactly one line — whether the cache is consulted before the fetch or only after it fails. Everything else, including the failure handling, is shared. That is the argument for having both rather than a boolean parameter: the boolean would hide a difference this small and this consequential behind a call-site true.

Pull-to-refresh on a device with the network off:

stream(id)        → Cached(Profile(name=…))      # instant, from disk
                  → Stale(Profile(name=…), UnknownHostException)

streamFresh(id)   → Stale(Profile(name=…), UnknownHostException)

streamFresh emits once, and the screen can honestly say "showing saved copy, couldn't refresh". A boolean-parameter version emits the same Profile with nothing to distinguish it from a successful refresh, which is how a user ends up staring at data from Tuesday.

The fourth line is what makes the first three usable. Without it, the screen receives values and has no way to distinguish the provisional one from the settled one, so it either renders both identically — and flickers — or guesses from timing, which is worse.

The pair also makes the cache window finally mean something. With one method it was encoding two requirements and could satisfy neither; with two, it governs exactly one question — how long cached content stays good enough for an ordinary open — and the refresh case no longer depends on it at all. A number that answers one question can be reasoned about, and often turns out to want a very different value from the compromise it had settled at.

That is the quiet benefit of separating intentions generally: not only does each path do the right thing, but every parameter underneath them stops being a negotiation between use cases that were never comparable.

None of this is more code than the tuned-cache-window version. It is the same work, with the decision moved to the one place that has the information needed to make it.

How we proved it

The structural claim is self-evident once stated and was not obvious before. The intention lives at the call site; a single method has no parameter for it; therefore the repository is guessing. That is not a measurement, it is an argument — and it is the kind of argument that is worth more than a measurement, because it explains why every cache-window value felt arbitrary.

The failure of tuning is observable. Any single window value can be shown to produce either a refresh that does nothing or a navigation that waits, by exercising both paths against it. That is the demonstration I would run for somebody unconvinced: there is no setting that satisfies both, and the search for one is the symptom.

The call-site audit is the cheap check and it is the one I would run on any codebase before arguing about cache windows. Find every pull-to-refresh handler and see what it calls. If it calls the same method as the screen's initial load, the gesture is decorative — it is a spinner attached to a request that was already going to be answered from cache. That takes minutes and it settles the question without a single measurement.

What I have not measured is the latency or network cost in either direction. I have no numbers for how often the ordinary path avoids a fetch or what the fresh path costs on a poor connection. The design is argued from intent-location rather than from profiling, and a measurement might well change where the thresholds sit — though not, I think, whether there are two paths.

What this did not fix, and I expected it to: the detail-screen case from the symptom section. Opening a detail screen from a list is an ordinary open, so it takes the ordinary path and shows cached content — which is right. The value still changes under the user when the background refresh lands. Separating the intentions fixed the refresh gesture and did nothing for that, because the problem there was never which path was taken; it was that the screen rendered a provisional value as though it were settled. That is the return-type half of the design doing the work, and it is a different fix that happens to live in the same change.

What I have not solved: the third read intention, which I suspect exists. Beyond "serve what you have" and "go and fetch", there is something like "fetch only if it is genuinely likely to have changed" — a prefetch or a speculative warm. I have not needed it, and the fact that I can name it suggests two methods is a convenient resting point rather than the complete decomposition.

One request, two intentions, and a path for each

And a limitation in how far this goes: the call site knows its own intention and not the user's. A pull-to-refresh is a clear signal; a screen open is ambiguous — the user may be returning after hours or after seconds, and the screen cannot tell. The ordinary path is still a heuristic, and all this design does is stop the unambiguous case being heuristic too.

What this generalizes to

When a component has to make a decision and the information it needs lives at the caller, the interface is the bug.

The usual response is to make the component cleverer — tune its heuristic, give it more state, let it learn. All of that is an attempt to reconstruct, downstream, something that was known precisely upstream and discarded at a function boundary. The reconstruction is always approximate, because the information was not merely absent but destroyed by the call.

So when a parameter is hard to tune, ask who knows the right answer. If somebody does know — the caller, the user, the context — the fix is a way for them to say it, not a better guess. And prefer naming the intentions over a flag, because the name survives at the call site where the next reader will be, and a boolean has to be looked up.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS