PersistedCache
PersistedCache is a two-layer cache for Persistable
request keys. Each get(key) walks three layers in order:
- Process-local memory — a
Cachekeyed by the request value, bounded byinMemoryCapacityandinMemoryTTL. - Durable persistence — a named
Persistencestore, selected bystoreId, that holds the encoded resultExit. - The lookup — your
(key) => Effect<Success, Error, R>, run only when neither layer has the value.
Both successes and failures are stored as Exit values, so a failed lookup
is remembered too (and replayed) for as long as the persistent TTL allows. With
an in-memory backing store the cache de-duplicates work within a single runtime;
with SQL, Redis, or KeyValueStore backing it survives process restarts and is
shared across workers that point at the same store.
import { Effect, Schema } from "effect"import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"
// 1. Describe the request as a Persistable.Class:// - the tag and payload become the request shape// - primaryKey is the durable entry id (must be stable + collision-free)// - success/error are the schemas used to encode/decode the stored Exitclass GetUser extends Persistable.Class<{ payload: { id: number } }>()("GetUser", { primaryKey: ({ id }) => `GetUser:${id}`, success: Schema.Struct({ id: Schema.Number, name: Schema.String }), error: Schema.Never}) {}
const program = Effect.gen(function*() { const cache = yield* PersistedCache.make( // The lookup only runs on a miss in BOTH layers Effect.fn("getUser")(function*(req: GetUser) { yield* Effect.log(`fetching user ${req.id}`) return { id: req.id, name: `User ${req.id}` } }), { storeId: "users", // TTL for the DURABLE layer, computed per result + request timeToLive: (exit) => (exit._tag === "Success" ? "1 hour" : "1 minute") } )
const first = yield* cache.get(new GetUser({ id: 1 })) // logs "fetching user 1" const second = yield* cache.get(new GetUser({ id: 1 })) // no log: served from memory // => first and second are the same { id: 1, name: "User 1" } return [first, second]})
// make needs Persistence + Scope; provide a backing layer at the edgeEffect.runPromise( Effect.scoped(program.pipe(Effect.provide(Persistence.layerMemory))))Surviving a restart
Section titled “Surviving a restart”Because the durable layer stores the encoded Exit, the same request resolved in
a previous process (or by another worker) is replayed without re-running the
lookup — as long as the entry is still within its timeToLive and the schemas /
primaryKey / storeId are unchanged.
import { Effect, Schema } from "effect"import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"import { SqlClient } from "effect/unstable/sql"
class GetReport extends Persistable.Class<{ payload: { day: string } }>()("GetReport", { primaryKey: ({ day }) => `GetReport:${day}`, success: Schema.Struct({ day: Schema.String, total: Schema.Number })}) {}
const makeCache = PersistedCache.make( Effect.fn("buildReport")(function*(req: GetReport) { // expensive aggregation runs once per day, then is reused across restarts return { day: req.day, total: 42 } }), { storeId: "daily-reports", timeToLive: () => "24 hours" })
// Persistence.layerSql persists results in a shared `effect_persistence` table.// It requires a SqlClient layer (chosen per dialect at the edge).declare const SqlLive: import("effect").Layer.Layer<SqlClient.SqlClient>
const program = Effect.scoped( Effect.gen(function*() { const cache = yield* makeCache return yield* cache.get(new GetReport({ day: "2026-05-30" })) // => { day: "2026-05-30", total: 42 } — recomputed only if the row expired })).pipe(Effect.provide(Persistence.layerSql), Effect.provide(SqlLive))Invalidating entries
Section titled “Invalidating entries”When the underlying data changes, call invalidate(key) so both layers forget
it. It removes the persisted value first, then drops the in-memory entry, so
the next get runs a fresh lookup.
import { Effect, Schema } from "effect"import { PersistedCache, Persistable } from "effect/unstable/persistence"
class GetUser extends Persistable.Class<{ payload: { id: number } }>()("GetUser", { primaryKey: ({ id }) => `GetUser:${id}`, success: Schema.Struct({ id: Schema.Number, name: Schema.String })}) {}
declare const cache: PersistedCache.PersistedCache<GetUser>declare const req: GetUser
const refresh = Effect.gen(function*() { yield* cache.invalidate(req) // removes persisted Exit, then the memory entry return yield* cache.get(req) // re-runs the lookup and re-stores both layers})Tuning the two layers
Section titled “Tuning the two layers”The memory layer and the durable layer are tuned independently:
| Layer | Option | Default | Controls |
|---|---|---|---|
| Memory | inMemoryTTL | () => "10 seconds" | How long a result stays in the process-local Cache. |
| Memory | inMemoryCapacity | 1024 | Max number of entries kept in memory before eviction. |
| Durable | timeToLive | (required) | How long the encoded Exit is reused from the persistence store. |
| Durable | storeId | (required) | The persistence store namespace (prefix / table / partition). |
Both inMemoryTTL and timeToLive are Persistable.TimeToLiveFn — they receive
the result Exit and the request, so you can give successes and failures
different lifetimes (a zero or negative durable TTL skips the persistent write
entirely).
import { Effect, Schema } from "effect"import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"
class Lookup extends Persistable.Class<{ payload: { key: string } }>()("Lookup", { primaryKey: ({ key }) => `Lookup:${key}`, success: Schema.String, error: Schema.String}) {}
const make = PersistedCache.make( Effect.fn("lookup")(function*(req: Lookup) { return req.key.toUpperCase() }), { storeId: "lookups", inMemoryCapacity: 256, inMemoryTTL: () => "30 seconds", // cache successes for an hour, but don't persist failures at all timeToLive: (exit) => (exit._tag === "Success" ? "1 hour" : "0 millis") })requireServicesAt
Section titled “requireServicesAt”A Persistable schema (and your lookup) may require services to decode/encode or
run. requireServicesAt decides where those services are supplied, which
shifts them between the cache’s R parameter and the R of each get:
"construction"(the default behavior when omitted) — services are provided once whenmakeruns. The resultingPersistedCache<K, never>and itsgetcarry no extra requirement, but those services must be in scope atmake."lookup"— services are provided per read.makeproduces aPersistedCache<K, R>and eachgetkeepsRin its requirement channel, so the services are pulled from the calling fiber’s context.
import { Effect, Schema } from "effect"import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"
class Tax extends Persistable.Class<{ payload: { amount: number }, requires: never }>()("Tax", { primaryKey: ({ amount }) => `Tax:${amount}`, success: Schema.Number}) {}
// With "lookup", R from the lookup stays on `get` instead of being baked into make.const make = PersistedCache.make( Effect.fn("tax")(function*(req: Tax) { return req.amount * 0.2 }), { storeId: "tax", timeToLive: () => "1 hour", requireServicesAt: "lookup" })Reference
Section titled “Reference”Import everything from effect/unstable/persistence:
import { PersistedCache } from "effect/unstable/persistence"PersistedCache<K, R>
Section titled “PersistedCache<K, R>”The cache interface, parameterized by the Persistable key type K and the
residual requirements R (non-never only when requireServicesAt: "lookup").
Fields:
inMemory— the underlyingCache:Cache<K, Persistable.Success<K>, Persistable.Error<K> | PersistenceError | SchemaError, Persistable.Services<K> | R>. Use it for direct cache operations likeCache.sizeorCache.invalidateAll.get(key)—Effect<Persistable.Success<K>, Persistable.Error<K> | PersistenceError | SchemaError, Persistable.Services<K> | R>. Reads memory, then persistence, then runs the lookup; the error channel unions your lookup’s errors with persistence and schema-decoding errors.invalidate(key)—Effect<void, PersistenceError>. Removes the persisted entry first, then the in-memory entry.
import { Cache, Effect, Schema } from "effect"import { PersistedCache, Persistable } from "effect/unstable/persistence"
class MyReq extends Persistable.Class<{ payload: { id: number } }>()("MyReq", { primaryKey: ({ id }) => `MyReq:${id}`, success: Schema.String}) {}
declare const cache: PersistedCache.PersistedCache<MyReq>
const program = Effect.gen(function*() { // reach through to the underlying Cache for memory-level operations const inMemorySize = yield* Cache.size(cache.inMemory) // => number of entries currently held in the process-local memory layer return inMemorySize})make(lookup, options)
Section titled “make(lookup, options)”Creates a scoped PersistedCache for a Persistable request type. Reads
persisted exits before running lookup, stores lookup exits under the durable
TTL, and keeps a scoped in-memory cache.
Signature shape:
PersistedCache.make( lookup: (key: K) => Effect.Effect<Persistable.Success<K>, Persistable.Error<K>, R>, options: { readonly storeId: string // durable store namespace readonly timeToLive: Persistable.TimeToLiveFn<K> // durable TTL (exit, request) => Duration.Input readonly inMemoryCapacity?: number | undefined // default 1024 readonly inMemoryTTL?: Persistable.TimeToLiveFn<K> | undefined // default 10 seconds readonly requireServicesAt?: "lookup" | "construction" | undefined }): Effect.Effect< PersistedCache<K, "lookup" extends ServiceMode ? R : never>, never, ("lookup" extends ServiceMode ? never : R) | Persistence.Persistence | Scope.Scope>The return type always requires Persistence.Persistence and Scope.Scope.
A complete runnable program providing the in-memory backing layer:
import { Effect, Schema } from "effect"import { PersistedCache, Persistable, Persistence } from "effect/unstable/persistence"
class Square extends Persistable.Class<{ payload: { n: number } }>()("Square", { primaryKey: ({ n }) => `Square:${n}`, success: Schema.Number}) {}
const program = Effect.scoped( Effect.gen(function*() { const cache = yield* PersistedCache.make( Effect.fn("square")(function*(req: Square) { return req.n * req.n }), { storeId: "squares", timeToLive: () => "10 minutes" } )
const a = yield* cache.get(new Square({ n: 4 })) // computes const b = yield* cache.get(new Square({ n: 4 })) // memory hit return [a, b] })).pipe(Effect.provide(Persistence.layerMemory))
Effect.runPromise(program).then(console.log)// => [ 16, 16 ]