Recipe: caching server state, and knowing when to throw it away
Cross-platform: Kotlin Multiplatform, Flutter, React Native.
Problem
The events list does not change second to second, so refetching it on every glance is waste — a spinner the user didn't need and a request the server didn't want. But it does change the instant someone reserves a seat, so caching it forever is a lie: the app shows availability that is already gone. Caching is easy; invalidation is the whole job.
Concept
Cache reads for a short TTL that matches the server's own Cache-Control: max-age=30, so a
pull-to-refresh inside the window is instant and shows no spinner. Then invalidate explicitly
on the one event that changes availability — a reservation. Cache only successes; never
remember a failure.
Implementation ×3
KMP — a read-through decorator, data/cache/CachingEventRepository.kt, wrapping the real
repository. The time source is injected so the TTL is testable without waiting:
override suspend fun listEvents(cursor: String?, limit: Int?): Outcome<EventPage> {
if (cursor == null) pageEntry?.let { if (it.mark.elapsedNow() < ttl) return Outcome.Success(it.value) }
val result = delegate.listEvents(cursor, limit)
if (cursor == null && result is Outcome.Success) pageEntry = Entry(result.value, timeSource.markNow())
return result
}
fun invalidate() { pageEntry = null; detailEntries.clear() }
The app shell calls invalidate() when a reservation succeeds.
Flutter — the same decorator, lib/data/cache.dart, with an injected DateTime Function()
clock; the app calls invalidate() in the reserve handler.
React Native — TanStack Query is the cache. Reads set a staleTime, and the reserve
handler invalidates:
useQuery({ queryKey: ['events'], queryFn: …, staleTime: 30000 }); // 30s, matches the server
// on reserve:
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['event'] });
Comparison
KMP and Flutter hand-roll a tiny read-through decorator behind the repository port — a dozen lines, fully in the app's control, and unit-tested with an injected clock. React Native gets caching, background refetch, and invalidation from TanStack Query for free, at the cost of a dependency and its query-key discipline. Same two ideas everywhere: a TTL, and an explicit invalidation keyed to the mutation that changes the data.
How to see it work
The decorators are unit-tested (KMP and Flutter): a second read inside the TTL serves from
cache (the delegate is called once), a read after the TTL refetches, and invalidate() forces
a refetch. In all three apps, reserving a seat drops the events/detail cache so the next view
reflects the new availability.
Trade-offs
A 30-second TTL is a guess that matches the server's header; too long and availability goes stale between invalidations, too short and the cache barely helps. Explicit invalidation is precise but manual — you have to remember every mutation that changes cached data (here, only the reservation). TanStack Query automates more (stale-while-revalidate, refetch on focus) but is a library to learn; the hand-rolled decorator is transparent but does exactly and only what it says.