Skip to content

Cache & ScopedCache

A Cache<Key, A, E, R> is a mutable, effectful key-value store. You give it a lookup function — (key: Key) => Effect<A, E, R> — and a capacity. Reading a key with Cache.get returns the cached value if present, or runs the lookup and stores the result. The headline feature is concurrency: when many fibers request the same missing key at once, the lookup runs once and every caller awaits the same result.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
// The lookup is the only place the "real" work lives. Here we pretend
// it is an expensive call; the cache makes sure it runs at most once
// per key while the entry is alive.
let lookups = 0
const cache = yield* Cache.make({
capacity: 100,
lookup: (key: string) =>
Effect.sync(() => {
lookups++
return key.length
})
})
// Three concurrent gets of the same key: the lookup runs once,
// and all three callers receive the same value.
const results = yield* Effect.all(
[Cache.get(cache, "hello"), Cache.get(cache, "hello"), Cache.get(cache, "hello")],
{ concurrency: "unbounded" }
)
return { results, lookups } // { results: [5, 5, 5], lookups: 1 }
})

The lookup returns an Effect, so it covers both synchronous computation and asynchronous I/O uniformly. The E and R type parameters flow from the lookup: a cache whose lookup can fail with E produces a Cache<Key, A, E>, and Cache.get fails with that same E.

Use Cache.make when every entry should share one TTL, and Cache.makeWith when the lifetime depends on the result or the key.

import { Cache, Effect } from "effect"
interface User {
readonly id: number
readonly name: string
}
// A cache of users keyed by id. Entries live for 15 minutes, then the
// next read re-runs the lookup. Capacity caps the number of entries.
const makeUserCache = (fetchUser: (id: number) => Effect.Effect<User, string>) =>
Cache.make({
capacity: 500,
timeToLive: "15 minutes",
lookup: fetchUser
})

TTLs accept a Duration input — a string like "15 minutes", a Duration value, or Duration.infinity (the default) for entries that never expire on their own.

capacity bounds how many entries the cache holds. The cache tracks access order: reading an entry moves it to the back, and when the cache overflows the oldest (least recently used) entries are evicted first. The size may briefly exceed capacity between operations.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 2,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.get(cache, "a")
yield* Cache.get(cache, "b")
yield* Cache.get(cache, "a") // touches "a", making "b" the oldest
yield* Cache.get(cache, "c") // overflow: evicts "b"
return {
a: yield* Cache.has(cache, "a"), // true
b: yield* Cache.has(cache, "b"), // false (evicted)
c: yield* Cache.has(cache, "c") // true
}
})

Beyond get, the cache exposes a full set of operations:

OperationWhat it does
Cache.getReturn the cached value, or run the lookup on a miss/expiry.
Cache.getOptionRead an existing entry without running the lookup; None if absent.
Cache.getSuccessLike getOption, but only for already-resolved successful entries.
Cache.setInsert or overwrite a value directly, skipping the lookup.
Cache.refreshForce a fresh lookup, resetting the TTL and overwriting the old value.
Cache.invalidate / invalidateAllEvict one entry, or clear the whole cache.
Cache.invalidateWhenEvict an entry only if its value matches a predicate.
Cache.has, size, keys, values, entriesInspect the cache contents.

A common pattern is a service that wraps a cache and exposes only the operations callers should use:

import { Cache, Context, Effect, Layer } from "effect"
// A service backed by a cache. Construction builds the cache once and
// captures it; the public method just delegates to Cache.get.
class UserRepo extends Context.Service<UserRepo, {
readonly byId: (id: number) => Effect.Effect<string, string>
readonly invalidate: (id: number) => Effect.Effect<void>
}>()("app/UserRepo") {
static layer = Layer.effect(
UserRepo,
Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10_000,
timeToLive: "5 minutes",
// In a real app this would hit a database or HTTP API.
lookup: (id: number): Effect.Effect<string, string> =>
id < 0 ? Effect.fail(`invalid id: ${id}`) : Effect.succeed(`user-${id}`)
})
return UserRepo.of({
byId: (id) => Cache.get(cache, id),
invalidate: (id) => Cache.invalidate(cache, id)
})
})
)
}

refresh differs from invalidate: invalidate removes the entry so the next get recomputes it, while refresh recomputes it now and keeps serving the old value to any concurrent readers until the new one is ready.

When there is no key — you just want to run one effect once and reuse its result — reach for the Effect.cached family instead of building a whole cache. Each returns an effect that, when run, yields a new memoized effect.

import { Effect } from "effect"
const program = Effect.gen(function*() {
let runs = 0
const expensive = Effect.sync(() => ++runs)
// `cached` is an Effect<A, E, R> that reuses its first result.
const cached = yield* Effect.cached(expensive)
const a = yield* cached // runs the effect -> 1
const b = yield* cached // reuses the cached result -> 1
return { a, b, runs } // { a: 1, b: 1, runs: 1 }
})

Two TTL-aware variants build on this:

  • Effect.cachedWithTTL(self, duration) — caches the result for duration, then recomputes on the next evaluation after it expires.
  • Effect.cachedInvalidateWithTTL(self, duration) — same, but also returns an invalidate effect so you can clear the cache before it expires.
import { Effect } from "effect"
const program = Effect.gen(function*() {
const expensive = Effect.sync(() => Math.random())
// Returns a tuple: the cached effect, plus an effect that clears it.
const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL(
expensive,
"1 minute"
)
const first = yield* cached
const same = yield* cached // cached: identical to `first`
yield* invalidate // drop the cached value early
const fresh = yield* cached // recomputed
return { first, same, fresh }
})

ScopedCache: caching values that own resources

Section titled “ScopedCache: caching values that own resources”

When a lookup acquires a resource — opens a connection, starts a worker, subscribes to a stream — you cannot simply cache the value and forget about it, because the resource must eventually be released. ScopedCache solves this: each entry owns its own Scope, and that scope is closed when the entry expires, is evicted, is invalidated, or when the cache’s owning scope closes.

import { Effect, ScopedCache } from "effect"
// A pretend connection that logs when opened and closed.
const openConnection = (host: string) =>
Effect.acquireRelease(
Effect.as(Effect.log(`open ${host}`), { host }),
() => Effect.log(`close ${host}`)
)
const program = Effect.gen(function*() {
// The cache itself is scoped: it requires a Scope and tears down all
// remaining entries when that scope closes.
const cache = yield* ScopedCache.make({
capacity: 4,
timeToLive: "1 minute",
lookup: openConnection
})
// First get acquires the connection; the second shares it.
const a = yield* ScopedCache.get(cache, "db-1")
const b = yield* ScopedCache.get(cache, "db-1")
// a and b are the same acquired connection.
// Invalidating closes that entry's scope, releasing the connection.
yield* ScopedCache.invalidate(cache, "db-1")
}).pipe(Effect.scoped)

The API mirrors Cacheget, getOption, set, refresh, invalidate, has, keys, and so on — but the lookup’s type is (key: Key) => Effect<A, E, R | Scope>, and the resource lifecycle is managed for you.

This section walks every public export of the Cache module. Each entry has a short description and a runnable snippet with inline // => result comments.

Creates a cache with a single fixed TTL shared by every entry (defaulting to Duration.infinity — entries never expire on their own). Returns Effect<Cache<Key, A, E, R>>; the R of the lookup flows out as a requirement of the constructor unless you capture it with requireServicesAt: "lookup".

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 100,
timeToLive: "15 minutes",
lookup: (key: string) => Effect.succeed(key.length)
})
return yield* Cache.get(cache, "hello") // => 5
})

Like make, but the TTL is computed per entry from the lookup Exit and key: (exit, key) => Duration.Input. Use Exit.isSuccess to give successes and failures different lifetimes. Note the lookup is passed as the first argument here (not inside the options object).

import { Cache, Effect, Exit } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.makeWith(
(id: number) => Effect.succeed({ id, active: id % 2 === 0 }),
{
capacity: 1000,
// Cache active users for an hour, inactive for 5 minutes,
// and retry failures quickly.
timeToLive: (exit) =>
Exit.isSuccess(exit)
? (exit.value.active ? "1 hour" : "5 minutes")
: "30 seconds"
}
)
return yield* Cache.get(cache, 2) // => { id: 2, active: true }
})

Returns the cached value, or runs the lookup on a miss or expired entry. Concurrent misses for the same key share one in-flight lookup, and both successes and failures are cached as Exit values.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
let lookups = 0
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.sync(() => (lookups++, key.length))
})
yield* Cache.get(cache, "hello") // => 5 (runs the lookup)
yield* Cache.get(cache, "hello") // => 5 (cached, no lookup)
return lookups // => 1
})

Reads an existing entry without triggering the lookup, as Effect<Option<A>, E>. Returns Option.none() on a miss or expiry, awaits a pending entry, and fails with E if the cached entry is a failure.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
const before = yield* Cache.getOption(cache, "hello") // => Option.none()
yield* Cache.get(cache, "hello")
const after = yield* Cache.getOption(cache, "hello") // => Option.some(5)
return { before, after }
})

Like getOption, but only for entries that have already resolved successfully. Returns Option.none() for missing, expired, failed, or still-pending entries — and never fails (Effect<Option<A>>).

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make<string, number, string>({
capacity: 10,
lookup: (key) =>
key === "bad" ? Effect.fail("nope") : Effect.succeed(key.length)
})
yield* Effect.exit(Cache.get(cache, "bad")) // cache the failure
yield* Cache.get(cache, "ok")
return {
ok: yield* Cache.getSuccess(cache, "ok"), // => Option.some(2)
bad: yield* Cache.getSuccess(cache, "bad") // => Option.none()
}
})

Seeds or overwrites a key with a ready value, skipping the lookup. The cache’s TTL policy is applied (using a successful Exit), and capacity is enforced.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 100,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.set(cache, "hello", 42)
return yield* Cache.get(cache, "hello") // => 42 (not 5 from the lookup)
})

Returns whether a non-expired entry exists for the key, without running the lookup. Expired entries report false.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
const before = yield* Cache.has(cache, "hello") // => false
yield* Cache.get(cache, "hello")
const after = yield* Cache.has(cache, "hello") // => true
return { before, after }
})

Drops a single key. Missing keys are a no-op; the next get recomputes the value.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.get(cache, "hello")
yield* Cache.invalidate(cache, "hello")
return yield* Cache.has(cache, "hello") // => false
})

Drops a key only if a predicate holds for its cached value, returning whether it removed anything. Returns false for missing, failed, or non-matching entries.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.get(cache, "hello") // value 5
const dropped = yield* Cache.invalidateWhen(cache, "hello", (n) => n === 5)
// => true
const present = yield* Cache.has(cache, "hello") // => false
return { dropped, present }
})

Clears every entry from the cache at once.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.get(cache, "a")
yield* Cache.get(cache, "b")
yield* Cache.invalidateAll(cache)
return yield* Cache.size(cache) // => 0
})

Recomputes a key by running the lookup again, overwriting the old value and resetting its TTL. Unlike invalidate, the previous value keeps being served to concurrent readers until the new lookup resolves.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
let counter = 0
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.sync(() => `${key}-${++counter}`)
})
yield* Cache.get(cache, "user") // => "user-1"
yield* Cache.refresh(cache, "user") // => "user-2"
return yield* Cache.get(cache, "user") // => "user-2"
})

The approximate number of stored entries. Expired entries are counted until a later operation observes and removes them.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.get(cache, "a")
yield* Cache.get(cache, "b")
return yield* Cache.size(cache) // => 2
})

Returns an Iterable<Key> of currently active keys, filtering out (and removing) expired entries as it goes.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.get(cache, "hello")
yield* Cache.get(cache, "world")
const keys = yield* Cache.keys(cache)
return Array.from(keys).sort() // => ["hello", "world"]
})

Returns an Iterable<A> of all successfully resolved, non-expired values (failed and pending entries are excluded).

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.get(cache, "a")
yield* Cache.get(cache, "abc")
const values = yield* Cache.values(cache)
return Array.from(values).sort() // => [1, 3]
})

Returns an Iterable<[Key, A]> of all successfully resolved, non-expired key-value pairs.

import { Cache, Effect } from "effect"
const program = Effect.gen(function*() {
const cache = yield* Cache.make({
capacity: 10,
lookup: (key: string) => Effect.succeed(key.length)
})
yield* Cache.get(cache, "a")
yield* Cache.get(cache, "bb")
const entries = yield* Cache.entries(cache)
return Array.from(entries).sort() // => [["a", 1], ["bb", 2]]
})

Cache<Key, A, E, R> is the cache value itself. Its type parameters are the key type, the success value, the lookup error E, and the lookup requirements R. You normally pass it to the combinators above rather than touching its fields, but it exposes capacity, lookup, timeToLive, and the underlying map.

import { Cache, Effect } from "effect"
// Annotate a cache you accept as a parameter.
const total = (cache: Cache.Cache<string, number>) =>
Effect.map(Cache.values(cache), (vs) =>
Array.from(vs).reduce((a, b) => a + b, 0))

Cache.Entry<A, E> is a low-level stored entry: an expiresAt timestamp (undefined when it never expires) and a Deferred<A, E> holding the in-flight or completed lookup result. You only encounter it when inspecting the cache’s internal map.

import type { Cache } from "effect"
// The shape stored per key inside `cache.map`.
type Entry = Cache.Entry<number, string>
// => { expiresAt: number | undefined; deferred: Deferred<number, string> }

ScopedCache mirrors the Cache API almost name-for-name, with two key differences: the lookup may acquire resources (its type is (key) => Effect<A, E, R | Scope.Scope>), and the cache must be built inside a Scope. Each entry owns its own scope, finalized when the entry is evicted, expires, is invalidated/replaced, or when the owning scope closes. Reads, inspection, and invalidation work just like Cache; only the lifecycle differs.

Creates a scoped cache with a fixed TTL. Requires a Scope in context and yields Effect<ScopedCache<Key, A, E, R>, never, R | Scope>.

import { Effect, ScopedCache } from "effect"
// Each cached value is an acquired connection; the cache releases
// remaining connections when its owning scope closes.
const openConnection = (host: string) =>
Effect.acquireRelease(
Effect.as(Effect.log(`open ${host}`), { host }),
() => Effect.log(`close ${host}`)
)
const program = ScopedCache.make({
capacity: 4,
timeToLive: "1 minute",
lookup: openConnection
}).pipe(
Effect.flatMap((cache) => ScopedCache.get(cache, "db-1")),
Effect.scoped
)
// => { host: "db-1" } (logs "open db-1", then "close db-1" on scope close)

The dynamic-TTL variant. The lookup goes inside the options object here (unlike Cache.makeWith), and timeToLive is (exit, key) => Duration.Input.

import { Effect, Exit, ScopedCache } from "effect"
const program = ScopedCache.makeWith({
capacity: 100,
lookup: (host: string) =>
Effect.acquireRelease(
Effect.succeed({ host }),
() => Effect.log(`close ${host}`)
),
// Keep successful connections for an hour; expire failures quickly.
timeToLive: (exit) => (Exit.isSuccess(exit) ? "1 hour" : "5 seconds")
})

Returns the cached value or runs the lookup in a fresh entry scope on a miss. Concurrent misses share one lookup; the resource is acquired once and reused.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
let opened = 0
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) =>
Effect.acquireRelease(
Effect.sync(() => (opened++, { host })),
() => Effect.void
)
})
yield* ScopedCache.get(cache, "db-1")
yield* ScopedCache.get(cache, "db-1") // shares the acquired resource
return opened // => 1
}).pipe(Effect.scoped)

Reads an existing unexpired entry without starting a lookup, as Effect<Option<A>, E>. Option.none() on miss/expiry; awaits pending entries.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
return yield* ScopedCache.getOption(cache, "db-1") // => Option.none()
}).pipe(Effect.scoped)

Inspects an already-resolved successful entry as Effect<Option<A>>. Option.none() for missing, expired, failed, or pending entries; never fails.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
yield* ScopedCache.get(cache, "db-1")
return yield* ScopedCache.getSuccess(cache, "db-1") // => Option.some({ host: "db-1" })
}).pipe(Effect.scoped)

Seeds a key with a ready value in a fresh entry scope, closing any prior scope for that key and applying the TTL.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
yield* ScopedCache.set(cache, "db-1", { host: "preset" })
return yield* ScopedCache.get(cache, "db-1") // => { host: "preset" }
}).pipe(Effect.scoped)

Whether a non-expired entry exists, without running the lookup. Closes scopes of expired entries it encounters.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
yield* ScopedCache.get(cache, "db-1")
return yield* ScopedCache.has(cache, "db-1") // => true
}).pipe(Effect.scoped)

Removes one key and closes its entry scope, releasing the resource. No-op if absent.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) =>
Effect.acquireRelease(
Effect.as(Effect.log(`open ${host}`), { host }),
() => Effect.log(`close ${host}`)
)
})
yield* ScopedCache.get(cache, "db-1") // logs "open db-1"
yield* ScopedCache.invalidate(cache, "db-1") // logs "close db-1"
}).pipe(Effect.scoped)

Removes a key (and closes its scope) only when a predicate holds for its successful value. Returns whether it removed anything; false for failed, missing, expired, or non-matching entries.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host, stale: host === "db-1" })
})
yield* ScopedCache.get(cache, "db-1")
return yield* ScopedCache.invalidateWhen(cache, "db-1", (v) => v.stale)
// => true (entry removed, scope closed)
}).pipe(Effect.scoped)

Recomputes a key by running the lookup in a new scope, then closes the old entry’s scope once the new value resolves. The previous value stays available to concurrent readers until then.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
let n = 0
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host, gen: ++n })
})
yield* ScopedCache.get(cache, "db-1") // => { host: "db-1", gen: 1 }
return yield* ScopedCache.refresh(cache, "db-1") // => { host: "db-1", gen: 2 }
}).pipe(Effect.scoped)

Removes every entry and closes every entry scope.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
yield* ScopedCache.get(cache, "db-1")
yield* ScopedCache.invalidateAll(cache)
return yield* ScopedCache.size(cache) // => 0
}).pipe(Effect.scoped)

Approximate number of stored entries (expired entries counted until observed); returns 0 once the cache is closed.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
yield* ScopedCache.get(cache, "db-1")
yield* ScopedCache.get(cache, "db-2")
return yield* ScopedCache.size(cache) // => 2
}).pipe(Effect.scoped)

An Array<Key> of active keys; closes scopes for expired entries while filtering.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
yield* ScopedCache.get(cache, "db-1")
yield* ScopedCache.get(cache, "db-2")
return (yield* ScopedCache.keys(cache)).sort() // => ["db-1", "db-2"]
}).pipe(Effect.scoped)

An Array<A> of successfully resolved, non-expired values.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
yield* ScopedCache.get(cache, "db-1")
return yield* ScopedCache.values(cache) // => [{ host: "db-1" }]
}).pipe(Effect.scoped)

An Array<[Key, A]> of successfully resolved, non-expired key-value pairs.

import { Effect, ScopedCache } from "effect"
const program = Effect.gen(function*() {
const cache = yield* ScopedCache.make({
capacity: 4,
lookup: (host: string) => Effect.succeed({ host })
})
yield* ScopedCache.get(cache, "db-1")
return yield* ScopedCache.entries(cache) // => [["db-1", { host: "db-1" }]]
}).pipe(Effect.scoped)

ScopedCache<Key, A, E, R> is the cache value. Its lookup type carries a Scope requirement ((key) => Effect<A, E, R | Scope>); it exposes capacity, lookup, timeToLive, and its current state.

import { Effect, ScopedCache } from "effect"
// Annotate a scoped cache passed in as a dependency.
const firstKey = (cache: ScopedCache.ScopedCache<string, { host: string }>) =>
Effect.map(ScopedCache.keys(cache), (ks) => ks[0])

ScopedCache.State<K, A, E> is the cache’s lifecycle state: either { _tag: "Open", map } holding the entries, or { _tag: "Closed" } after the owning scope closes. Lookup operations interrupt once the state is Closed.

import type { ScopedCache } from "effect"
type State = ScopedCache.State<string, number, never>
// => { _tag: "Open"; map: MutableHashMap<...> } | { _tag: "Closed" }

ScopedCache.Entry<A, E> is a stored entry: an optional expiresAt, the shared Deferred<A, E> lookup result, and the Scope.Closeable that owns the entry’s resources. Removing the entry closes that scope.

import type { ScopedCache } from "effect"
type Entry = ScopedCache.Entry<{ host: string }, never>
// => { expiresAt: number | undefined; deferred: Deferred<...>; scope: Scope.Closeable }

If you need to share a live resource (where identity and cleanup matter more than memoizing a computed value), reach for reference counting instead — see RcMap and RcRef. If you have a single value that should be reloaded periodically, Resource is a better fit.