Caching without the cross-user data leak
I mentioned back in part one that a hand-rolled cache once briefly showed one staging user another user’s data because the cache key didn’t account for who was asking. This post is the fix, in detail — the three caching strategies, what actually goes into a cache key, and how invalidation works after a mutation.
Three strategies, three tradeoffs
cache-first network-first stale-while-revalidate
───────────── ───────────── ───────────────────────
cache? ──yes──▶ return network? ──ok──▶ return cache? ──yes──▶ return
│no │fail it NOW, then
▼ ▼ refresh quietly
network ──▶ return cache? ──▶ return │
& store it (fallback) ▼
network ──▶ update
cache for next timecache-first— fastest, but can serve stale data indefinitely if nothing ever busts the cache. Good for data that rarely changes: a list of countries, a plan’s feature flags.network-first— always tries fresh data, falls back to cache only if the network call fails. Good for anything where staleness is actively wrong: account balances, live status.stale-while-revalidate— the default I reach for most. Instant response from cache, silent background refresh, next call gets the fresh version. Good for almost everything else: user lists, dashboards, settings screens.
createClient({
cache: { strategy: 'stale-while-revalidate', ttl: 60_000 },
})
// override per module — search results go stale fast
const search = defineModule({
config: { cache: { strategy: 'network-first', ttl: 5_000 } },
methods: { /* ... */ },
})What’s actually in a cache key
Problem: if a cache key is just the URL, two different users hitting
GET /me get the exact same cache entry. On a shared computer, or in any app
with impersonation/admin-as-user features, that’s a real data leak — not a
hypothetical one, since it’s the actual bug I hit.
Solution: the cache key is built from more than the path. It factors in the method, the full URL including query params, and — this is the important part — an auth fingerprint derived from the current token, plus the active tenant ID if you’re using multi-tenancy (covered next post). Two different users, or the same user under two different tenants, can never read each other’s cached response, because their fingerprints produce different keys even for the identical URL.
GET /me
user A's token → key: GET:/me:fp_a1b2c3
user B's token → key: GET:/me:fp_9f8e7d ← different key, no collisionYou don’t configure this — it’s automatic, and it’s the kind of default
that’s only interesting when you understand what it prevents. A hand-rolled
Map<string, CachedResponse>() keyed purely on URL doesn’t have this
property unless someone specifically thinks to add it, which — real talk —
I didn’t, the first time.
Invalidating after a mutation
Problem: you POST /users to create a user, and your cached
GET /users list is now stale — it doesn’t include the new one until the
ttl expires, which could be a full minute of a missing row in the UI.
Solution: invalidate explicitly, right after the mutation:
create: async (ctx, body: { name: string; email: string }) => {
const result = (await ctx.request({ method: 'POST', path: '/users', body })).data
await ctx.cache.invalidate('users:list') // or a pattern, see below
return result
}You can also invalidate by pattern, which matters when a mutation affects more than one cached shape — say, updating a user should bust both the list cache and that specific user’s detail cache:
await ctx.cache.invalidatePattern('users:*')Bust vs disable, per call
Two different per-call needs that people mix up:
// "Ignore whatever's cached, get me the truth right now"
await api.users.get('42', { cache: { bust: true } })
// "Don't even look at or write to cache for this one call"
await api.users.get('42', { cache: { enabled: false } })bust: true still participates in caching — it forces a fresh fetch and
updates the cache with the new value. enabled: false opts the call out of
caching entirely, in both directions. I use bust after a mutation when I
want the next read to be fresh but still cached going forward; I use
enabled: false for things that should genuinely never be cached, like a
one-off export download.
Cache events — hooking into hits and misses
For debugging, or for building something like a small “data is fresh as of…” indicator, the client emits cache events you can listen to:
api.on('cache:hit', ({ key }) => console.debug('cache hit', key))
api.on('cache:miss', ({ key }) => console.debug('cache miss', key))
api.on('cache:invalidate', ({ pattern }) => console.debug('invalidated', pattern))This is a small preview of the fuller hooks-and-events system covered a few posts from now — worth knowing it exists here because cache visibility is usually the first thing I want when a UI is showing data I don’t expect.
Real talk: TTL is a starting guess, not a law
I’ve picked a TTL, shipped it, and had to revisit it within a week more times
than I’d like to admit. The right number depends entirely on how often the
underlying data actually changes and how bad a stale read is for that
specific screen. Start with stale-while-revalidate and a TTL in the tens of
seconds for most things, and tighten or loosen it based on actual complaints
— not by guessing upfront what “feels right.”
What sticks from this post
- Three strategies:
cache-firstfor rarely-changing data,network-firstfor anything where staleness is actively wrong,stale-while-revalidateas the sensible default for most UI data. - Cache keys include an auth fingerprint and tenant ID automatically — two users can never share a cached response, even for the identical URL.
bust: trueforces a fresh fetch and updates the cache;enabled: falseskips caching entirely for that call.- Invalidate explicitly after mutations, by key or by pattern, instead of waiting out the TTL.
Next: the resilience cluster — dedup, retries with backoff, timeouts, cancellation, and the concurrency queue. This is the post that explains why your app stops feeling flaky.
- developerehsan