ehsan.blog
~/blog/api-client-part-6-caching-strategies-keys — zsh
cat api-client-part-6-caching-strategies-keys.md

Caching without the cross-user data leak

·5 min read

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

plaintext
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 time
  • cache-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.
ts
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.

plaintext
GET /me
  user A's token  →  key: GET:/me:fp_a1b2c3
  user B's token  →  key: GET:/me:fp_9f8e7d   ← different key, no collision

You 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:

ts
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:

ts
await ctx.cache.invalidatePattern('users:*')

Bust vs disable, per call

Two different per-call needs that people mix up:

ts
// "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:

ts
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-first for rarely-changing data, network-first for anything where staleness is actively wrong, stale-while-revalidate as 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: true forces a fresh fetch and updates the cache; enabled: false skips 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.