07·02Cache

A `Cache<Key, Value, Error>` is a keyed memoizer with a bounded capacity, a time-to-live, and, critically, concurrent deduplication. If ten fibers all miss on the same key, the lookup runs once and ten fibers receive the same result.

47 - Cache: Concurrent-Safe Memoization

Previous · Next

A Cache<Key, Value, Error> is a keyed memoizer with a bounded capacity, a time-to-live, and, critically, concurrent deduplication. If ten fibers all miss on the same key, the lookup runs once and ten fibers receive the same result.

What you’ll work through

index.test.ts contains four beats: constructing a Cache with capacity and TTL via Cache.make, invalidating a key, reading hit/miss stats via cache.cacheStats, and observing concurrent deduplication — N simultaneous misses on the same key invoke the lookup once.

By the end you should reach for Cache whenever a lookup is expensive and the same key fires concurrently — DB lookups, remote fetches, computed properties.

Diagnose shape before choosing an optimization

Measure first, then match the primitive to the work you actually have:

  • Cache repeated results across time. It also provides single-flight behavior when concurrent callers request the same missing key.
  • Batch different concurrent keys with Request and RequestResolver.makeBatched when a downstream system supports bulk reads.
  • Pool expensive reusable resources such as database connections (step 38).
  • Semaphore bounds concurrency when the downstream system has limited capacity (step 38).

These solve different shapes. A cache does not make ten distinct keys into one database query, and batching does not retain results for later callers. Instrument hit rate, batch size, queue time, and downstream latency so the optimization can be evaluated and removed if it does not pay for its complexity.


Concept

Cache.make({ capacity, timeToLive, lookup })
cache.get(key)
├── HIT ──▶ return cached value
└── MISS ──▶ run lookup(key)
(concurrent misses on the same key share the lookup)
Eviction:
- capacity reached ──▶ least-recently-used entry removed
- timeToLive expired ──▶ entry recomputed on next get

A plain Map<Key, Promise<Value>> does not deduplicate under contention, the check, the fetch, and the write are three separate steps. Cache collapses them.


Cache.make

Constructs a Cache parameterized by the lookup function.

import { Cache, Duration, Effect } from "effect"
// ┌─── Effect<Cache<string, number, never>>
// ▼
const program = Effect.gen(function* () {
return yield* Cache.make({
capacity: 1000,
timeToLive: Duration.minutes(5),
lookup: (key: string) => Effect.succeed(key.length),
})
})

Details

  • capacity is the maximum number of entries. Beyond it, the LRU entry is evicted.
  • timeToLive is a Duration.DurationInput, accepts "5 minutes", Duration.seconds(30), Duration.infinity, etc.
  • lookup returns an Effect<Value, Error, R>. The cache propagates R into its own requirements.

cache.get

Returns the cached value if present, otherwise runs the lookup. Concurrent gets for the same missing key share the single in-flight lookup.

// ┌─── Effect<number, never>
// ▼
const length = cache.get("hello")

When to Use

When the same key is likely to be requested multiple times, by different fibers, by the same fiber, or across short time windows.


cache.invalidate and cache.refresh

Both bust the entry. invalidate deletes it (next get recomputes lazily).

refresh recomputes proactively while keeping the old value available until the new one is ready.

yield* cache.invalidate("config")
yield* cache.refresh("config")

cache.cacheStats

A snapshot of cache effectiveness. Use it to observe whether the cache is earning its keep.

// ┌─── Effect<{ readonly hits: number; readonly misses: number; readonly size: number }>
// ▼
const stats = cache.cacheStats

Effect.cached: One-Shot Memoization

For a single Effect (no key), Effect.cached returns an Effect<Effect<A, E, R>> that you unwrap once to get the memoized version.

// ┌─── Effect<Effect<{ env: string }>>
// ▼
const lazyConfig = Effect.cached(Effect.sync(() => ({ env: "production" })))
const program = Effect.gen(function* () {
const getConfig = yield* lazyConfig
const a = yield* getConfig // computes
const b = yield* getConfig // cached
})

Composite Example: Concurrent Deduplication

// ┌─── Effect<number> (number of times the lookup ran)
// ▼
const program = Effect.gen(function* () {
let calls = 0
const cache = yield* Cache.make({
capacity: 100,
timeToLive: Duration.minutes(5),
lookup: (key: string) =>
Effect.gen(function* () {
calls++
yield* Effect.sleep("5 millis")
return key.length
}),
})
yield* Effect.all(
Array.from({ length: 100 }, () => cache.get("hello")),
{ concurrency: "unbounded" },
)
return calls
})
// 1, one lookup served 100 fibers


TTL: entries expire regardless of hits

Cache.make takes a capacity (max entries) and a timeToLive (per-entry TTL). Once an entry is older than timeToLive, the next get on its key re-runs the lookup — even if the entry has been hit a hundred times in between.

TTL caches freshness, not popularity.

import { Cache, Effect, Duration } from "effect"
const program = Effect.gen(function* () {
let calls = 0
const cache = yield* Cache.make({
capacity: 100,
timeToLive: Duration.seconds(5),
lookup: (key: string) =>
Effect.sync(() => { calls++; return `value-${key}` }),
})
yield* cache.get("k") // calls === 1
yield* cache.get("k") // calls === 1 (hit)
// ...6 seconds later...
yield* cache.get("k") // calls === 2 (TTL expired)
})

For deterministic tests of TTL behavior, pair this with TestClock (step 30) so you can advance time without sleeping.

Your turn build a `Cache.make` with a 5-second TTL, hit it once, then advance `TestClock` past the TTL and assert the lookup runs again.


Common Pitfalls

1, Building the Cache inside a function body. Every call constructs a fresh Cache, so nothing is cached. Build it once during service initialization and share it via the closure or a Context Tag.

2, Using Effect.cached for keyed memoization. Effect.cached memoizes a single Effect with no parameters. For keyed memoization, use Cache.

3, Forgetting that lookup failures are cached. If the lookup fails, the failure is stored until the entry’s TTL expires. Use cache.refresh (which always re-runs the lookup regardless of prior failures) when you want a retry path.

4, Sharing capacity across unrelated keys. A single overflowing key family can evict unrelated entries. Use separate caches for unrelated workloads.


re-runs the lookup. - [ ] Read hit/miss counts via `cache.cacheStats`. - [ ] Trigger N concurrent misses on the same key and assert the lookup runs **once**.

See Also

  • Previous, 46-scope: Cache uses Scope for its internal cleanup.
  • Next, 42-queue: distribute work; the dual of deduplicating it.
  • Related, 40-synchronized-ref: the lower-level primitive Cache is built on top of.
  • Related, 32-retry-and-timeout: pairing cache refresh with retry policies.

Run the tests: pnpm test 47-cache


The full test suite

The index.test.ts for this lesson, verbatim from steps/47-cache/.

index.test.ts
import { describe, expect, it } from "vite-plus/test"
import { Cache, Effect, Ref } from "effect"
describe("Cache", () => {
it("caches by key", async () => {
const program = Effect.gen(function*() {
const calls = yield* Ref.make(0)
const cache = yield* Cache.make<string, number>({
capacity: 100,
lookup: (key) => Ref.update(calls, (n) => n + 1).pipe(Effect.as(key.length)),
})
const first = yield* Cache.get(cache, "effect")
const second = yield* Cache.get(cache, "effect")
return { first, second, calls: yield* Ref.get(calls) }
})
expect(await Effect.runPromise(program)).toEqual({ first: 6, second: 6, calls: 1 })
})
it("coalesces concurrent misses into single-flight work", async () => {
const program = Effect.gen(function*() {
const calls = yield* Ref.make(0)
const cache = yield* Cache.make<string, string>({
capacity: 10,
lookup: (key) => Ref.update(calls, (n) => n + 1).pipe(Effect.andThen(Effect.succeed(key))),
})
yield* Effect.all([Cache.get(cache, "same"), Cache.get(cache, "same")], { concurrency: 2 })
return yield* Ref.get(calls)
})
expect(await Effect.runPromise(program)).toBe(1)
})
})
← PreviousScopeNext →Cause & Sandbox