alepha@docs:~/framework/docs/guides/persistence$
cat 8-caching.md | pretty
7 min read
Last commit:

#Caching

$cache provides a caching system with two modes: automatic function result caching, and a key-value store that also works as a middleware. In-memory by default on Node and Bun, with Redis, database and Cloudflare backends.

Server-rendered pages need none of this: the page cache caches every page that reads nothing about its visitor, automatically, and refuses a $cache in a page's use. Use $cache for the data a personal page's loader reads, keyed by user.

typescript
1import { $cache } from "alepha/cache";

#Function Result Caching

Define a cache with a handler function. The return value is automatically cached based on the input arguments.

typescript
 1import { $cache } from "alepha/cache"; 2  3class UserService { 4  getUserData = $cache({ 5    name: "user-data", 6    ttl: [10, "minutes"], 7    handler: async (userId: string) => { 8      return await this.repo.findById(userId); 9    },10  });11}

Call it like a regular function. The first call executes the handler and caches the result. Subsequent calls with the same arguments return the cached value until the TTL expires.

typescript
1const user = await this.getUserData("some-uuid");

The cache key is derived from the function arguments by default (via JSON.stringify). If the handler throws an error, the result is not cached. Concurrent misses on the same key share one handler call (single-flight), and a failed cache write is logged, never thrown: the computed result is still returned.

With every option on (memory and stale, described below), a call walks this path:

Flowchart diagramyesnofreshstale, within windowmissCall with argsDerive keyL1 memory hit?Return valueProvider hit?Return stale valueOne background refreshRun handler once per keyWrite provider and L1

#Manual Store Operations

Omit the handler to use the cache as a manual key-value store.

typescript
 1import { $cache } from "alepha/cache"; 2  3class SessionService { 4  sessions = $cache({ name: "sessions", ttl: [1, "hour"] }); 5  6  async store(id: string, data: any) { 7    await this.sessions.set(id, data); 8  } 9 10  async get(id: string) {11    return await this.sessions.get(id);12  }13 14  async clear(id: string) {15    await this.sessions.invalidate(id);16  }17}

#As a middleware

Without a handler, the cache is also a middleware: put it in the use array of a $pipeline (or anything that takes middlewares) and it caches that handler's result, with the same key derivation, single-flight and stale behavior. The same object remains a store, so it can invalidate what it cached:

UserService.ts
 1import { $pipeline } from "alepha"; 2import { $cache } from "alepha/cache"; 3  4class UserService { 5  userCache = $cache({ name: "users", ttl: [10, "minutes"] }); 6  7  fetchUser = $pipeline({ 8    use: [this.userCache], 9    handler: async (userId: string) => this.repo.getById(userId),10  });11 12  async rename(userId: string, name: string) {13    await this.repo.updateById(userId, { name });14    await this.userCache.invalidate(JSON.stringify([userId]));15  }16}

A cache is a no-op until the container has started, when it is disabled, or when cacheOptions.enabled is false: reads miss, writes are dropped, and incr returns amount. The enabled getter tells you which, for code that uses the cache as infrastructure rather than as an optimisation.

#Options

Option Type Default Description
name string ClassName:propertyKey Cache namespace (the container part of every key)
handler Function - Function whose results are cached automatically
ttl DurationLike [300, "seconds"] (5 min) Time-to-live. Set 0 to disable expiration
key Function JSON.stringify(args) Custom key generator from handler arguments
provider Class or "memory" Injected CacheProvider Override the cache backend
compress boolean false Enable gzip compression (60-80% size reduction)
disabled boolean false Disable caching entirely
memory true or options off Add an in-process L1 memory tier in front of provider
stale DurationLike off Stale-while-revalidate window after ttl expires

#L1 Memory Tier

memory: true puts a process-local memory cache in front of the provider. Reads check memory first and fall back to the provider on miss; writes go to both tiers, so your own writes are immediately visible. Each process/isolate has its own L1 - invalidate() clears the local L1 plus the remote provider, but other processes keep their L1 until its TTL expires, so use a short L1 TTL to bound the staleness window.

Pass an object instead of true to tune it: ttl (default the smaller of the cache's ttl and 30 seconds), max (LRU bound, default 500 entries) and negative (also remember provider misses for that duration, off by default).

#Stale-While-Revalidate

With stale set, a value that outlived its ttl remains servable for the stale window: reads return the stale value immediately and trigger one background refresh (single-flight per key). Requires a handler, or middleware mode, since the cache needs to know how to recompute.

typescript
1getPrices = $cache({2  ttl: [1, "minute"],3  stale: [10, "minutes"],4  memory: true,5  handler: async () => this.fetchPrices(),6});

#TTL Format

TTL accepts a DurationLike: an [amount, unit] tuple, a Duration, or a number of milliseconds.

typescript
1ttl: [30, "seconds"];2ttl: [10, "minutes"];3ttl: [1, "hour"];4ttl: [7, "days"];5ttl: 1500; // a bare number is milliseconds

The default TTL is 5 minutes (300 seconds). It can be overridden globally via the cacheOptions atom:

typescript
1import { cacheOptions } from "alepha/cache";2 3alepha.store.mut(cacheOptions, (c) => ({ ...c, defaultTtl: 600 }));

#Methods

#run

Execute the handler function with caching. This is what gets called when you invoke the cache as a function. Only available when a handler is defined.

typescript
1const result = await this.getUserData("some-uuid");2// equivalent to:3const result = await this.getUserData.run("some-uuid");

#get

Retrieve a cached value by key. Returns undefined if the key does not exist or has expired.

typescript
1const value = await this.sessions.get("session-123");

#set

Store a value in the cache with an optional TTL override.

typescript
1await this.sessions.set("session-123", sessionData);2await this.sessions.set("session-123", sessionData, [30, "minutes"]);

#invalidate

Remove one or more keys from the cache. Supports pattern-based invalidation with a trailing wildcard *.

typescript
1// Invalidate a specific key2await this.sessions.invalidate("session-123");3 4// Invalidate multiple keys5await this.sessions.invalidate("session-123", "session-456");6 7// Pattern-based invalidation: delete all keys starting with "user:"8await this.cache.invalidate("user:*");

Calling invalidate() with no arguments deletes all entries in the cache's namespace.

#incr

Atomically increment a numeric value. If the key does not exist, it is set to 0 before incrementing.

typescript
1const newCount = await this.counter.incr("page-views", 1);

A counter gets a lifetime the same way a cached value does: the per-call ttl, else the cache's ttl, else the container-wide defaultTtl (300 seconds). Only the creation applies it, so the window is fixed rather than sliding and a counter cannot be held open by the very traffic it is meant to throttle.

typescript
1// Fixed 15-minute window, whatever the container default is.2const attempts = await this.counter.incr("login:1.2.3.4", 1, [15, "minutes"]);3 4// Never expires. Say so explicitly - the default is not "forever".5const total = await this.counter.incr("lifetime-signups", 1, 0);

#key

Get the cache key that would be generated for the given arguments (useful for debugging).

typescript
1const cacheKey = this.getUserData.key("some-uuid");

#Custom Key Generation

Override the default key derivation:

typescript
1getUserData = $cache({2  name: "user-data",3  ttl: [10, "minutes"],4  key: (userId: string) => `user:${userId}`,5  handler: async (userId: string) => {6    return await this.repo.findById(userId);7  },8});

#Compression

Enable gzip compression for cached values to reduce storage size. Useful for large JSON payloads.

typescript
1largeData = $cache({2  name: "large-data",3  ttl: [1, "hour"],4  compress: true,5  handler: async () => {6    return await this.repo.findMany({});7  },8});

Compression adds CPU overhead but reduces storage by 60-80% for typical JSON data.

#Redis Backend

By default, caching uses in-memory storage (MemoryCacheProvider). Set REDIS_URL and every instance shares one cache (RedisCacheProvider): there is no module to register. The same variable shares queue jobs, topics, locks and actors, so run that Redis with noeviction: see one Redis for every role.

Memory and Redis are not the only backends:

  • DatabaseCacheProvider (alepha/cache/database, module AlephaCacheDatabase) stores entries in a cache_entries table of the app's own database, with an atomic incr (INSERT ... ON CONFLICT DO UPDATE). On Node and Bun, registering the module does not change the default: pass provider: DatabaseCacheProvider to a $cache, or substitute it for CacheProvider. alepha/api/users and alepha/api/oauth register it.
  • On Cloudflare Workers the default is CloudflareCacheProvider, which resolves once, at start, to the database cache when the container registers alepha/cache/database, and to CloudflareKVProvider (Workers KV) only when it does not. KV is eventually consistent, has a 60 second TTL floor, and its incr can lose an update under concurrency.

#Selection

bash
REDIS_URL=redis://localhost:6379

REDIS_URL is read when alepha/cache registers: set and non-empty, the cache is RedisCacheProvider; absent, MemoryCacheProvider. An explicit substitution wins over both, so an app that sets REDIS_URL for its queue can keep this cache per instance:

typescript
1import { Alepha } from "alepha";2import { CacheProvider, MemoryCacheProvider } from "alepha/cache";3 4const alepha = Alepha.create().with({5  provide: CacheProvider,6  use: MemoryCacheProvider,7});

#Key Namespace

Redis and Cloudflare KV store every entry as <alepha.name>:cache:<container>:<key>, where alepha.name is APP_NAME, else the build's app name, else "app". Two apps sharing one Redis or one KV namespace never read each other's entries, and clear() deletes the calling app's entries only. Set APP_NAME to keep two environments of one app apart:

typescript
1import { Alepha } from "alepha";2 3const alepha = Alepha.create({ env: { APP_NAME: "shop-staging" } });

The database cache is not namespaced: its cache_entries table lives in the app's own database.

#Global Configuration

Caching is configured through state atoms, not environment variables:

Atom Field Default Description
cacheOptions enabled true Enable or disable all caching globally
cacheOptions defaultTtl 300 Default TTL in seconds
typescript
1import { cacheOptions } from "alepha/cache";2 3alepha.store.mut(cacheOptions, (c) => ({ ...c, enabled: false }));

#Events

Every cache emits cache:hit, cache:missed and cache:set ({ container, key }, plus ttlMs on set), and with stale, cache:staleServed when a stale value is served and cache:revalidated when its background refresh lands.

#Serialization

The cache automatically handles serialization:

  • JSON values: Serialized with JSON.stringify / JSON.parse
  • Strings: Stored as raw UTF-8
  • Uint8Array: Stored as raw binary

The serialization format is detected automatically during deserialization based on a type byte prefix.