#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
$cachein a page'suse. Use$cachefor the data a personal page's loader reads, keyed by user.
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.
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.
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:
#Manual Store Operations
Omit the handler to use the cache as a manual key-value store.
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:
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.
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.
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:
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.
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.
1const value = await this.sessions.get("session-123");
#set
Store a value in the cache with an optional TTL override.
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 *.
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.
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.
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).
1const cacheKey = this.getUserData.key("some-uuid");
#Custom Key Generation
Override the default key derivation:
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.
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, moduleAlephaCacheDatabase) stores entries in acache_entriestable of the app's own database, with an atomicincr(INSERT ... ON CONFLICT DO UPDATE). On Node and Bun, registering the module does not change the default: passprovider: DatabaseCacheProviderto a$cache, or substitute it forCacheProvider.alepha/api/usersandalepha/api/oauthregister it.- On Cloudflare Workers the default is
CloudflareCacheProvider, which resolves once, at start, to the database cache when the container registersalepha/cache/database, and toCloudflareKVProvider(Workers KV) only when it does not. KV is eventually consistent, has a 60 second TTL floor, and itsincrcan lose an update under concurrency.
#Selection
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:
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:
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 |
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.