Skip to content

PersistedCache

PersistedCache is a two-layer cache for Persistable request keys. Each get(key) walks three layers in order:

  1. Process-local memory — a Cache keyed by the request value, bounded by inMemoryCapacity and inMemoryTTL.
  2. Durable persistence — a named Persistence store, selected by storeId, that holds the encoded result Exit.
  3. 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 Exit
class 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 edge
Effect.runPromise(
Effect.scoped(program.pipe(Effect.provide(Persistence.layerMemory)))
)

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))

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
})

The memory layer and the durable layer are tuned independently:

LayerOptionDefaultControls
MemoryinMemoryTTL() => "10 seconds"How long a result stays in the process-local Cache.
MemoryinMemoryCapacity1024Max number of entries kept in memory before eviction.
DurabletimeToLive(required)How long the encoded Exit is reused from the persistence store.
DurablestoreId(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")
}
)

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 when make runs. The resulting PersistedCache<K, never> and its get carry no extra requirement, but those services must be in scope at make.
  • "lookup" — services are provided per read. make produces a PersistedCache<K, R> and each get keeps R in 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"
}
)

Import everything from effect/unstable/persistence:

import { PersistedCache } from "effect/unstable/persistence"

The cache interface, parameterized by the Persistable key type K and the residual requirements R (non-never only when requireServicesAt: "lookup").

Fields:

  • inMemory — the underlying Cache: Cache<K, Persistable.Success<K>, Persistable.Error<K> | PersistenceError | SchemaError, Persistable.Services<K> | R>. Use it for direct cache operations like Cache.size or Cache.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
})

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 ]