Caching
Cache query results in Redis, or in process when there is no Redis to run
Enable Caching
Pass a CacheConfig as the second argument to the Stabilize constructor. With a redisUrl the cache is shared between every instance of your application:
import { Stabilize, DBType } from "stabilize-orm";
const orm = new Stabilize( { type: DBType.Postgres, connectionString: process.env.DATABASE_URL, }, { enabled: true, ttl: 60, // Cache TTL in seconds redisUrl: process.env.REDIS_URL, // Shared across instances cachePrefix: "myapp:", // Prefix for all cache keys strategy: "cache-aside", // or "write-through" });Leave redisUrl out and the cache runs in process instead, backed by StabilizeKV. enabled: true always produces a working cache either way — there is no configuration that turns caching on and then quietly does nothing:
// No Redis to run. maxEntries bounds the store; the least recently// used entry is evicted when it is full.const orm = new Stabilize(dbConfig, { enabled: true, ttl: 60, maxEntries: 5000,});Passing existingClient turns caching off
When you construct Stabilize around a client you already own — new Stabilize(config, cacheConfig, loggerConfig, existingClient) — the cache handle is forced to null and cacheConfig is ignored entirely, enabled included. getCacheStats() then answers { hits: 0, misses: 0, keys: 0, backend: "disabled" } however you configured it, and reads go straight to the database. This is not a bug you can work around with config: if you need caching, do not pass existingClient.
The In-Process Backend
Without a redisUrl the cache is a StabilizeKV — an in-process key-value store with get/put/delete/list, expiration and expirationTtl, metadata and cursor pagination. Its API follows Cloudflare Workers KV, so code written against Workers KV works here unchanged. It is exported in its own right too, for an application that wants a KV store with TTL and cursors without running one:
import { StabilizeKV } from "stabilize-orm";
const kv = new StabilizeKV({ maxEntries: 5000 });
await kv.put("session:42", JSON.stringify(user), { expirationTtl: 3600 });const raw = await kv.get<string>("session:42", { type: "text" });
const { keys } = await kv.list({ prefix: "session:", limit: 100 });Both backends implement one internal interface, every public Cache method is written once against it, and the in-process store holds the JSON text Cache produced rather than live objects. So swapping backends cannot change what a get returns, and a caller who mutates a returned object cannot reach into the cache and corrupt it.
What it is not: shared, durable, or replicated
The in-process store is private to one process. Two application servers caching the same key each hold their own copy, and invalidating on one leaves the other serving stale rows. Nothing is written to disk, so the cache dies with the process — a restart starts cold, which is correct for a cache and wrong for anything you were treating as storage. And there is no eviction policy beyond the LRU bound you set: when maxEntries is reached, the least recently used entry goes. That is the right trade for a single-server app, a test suite and local development, and the wrong one for a fleet. Pass redisUrl when the cache has to be shared.
Cache Strategies
Stabilize supports two caching strategies:
- cache-aside — Read from cache first, fall back to the database on a miss. Nothing is written to the cache on save.
- write-through — The same reads, but every write also stores the row it just wrote, so the next
findOnefor that id is a hit.
In both strategies a write invalidates the affected keys. The difference is only whether the write also populates the cache afterwards — cache-aside leaves it empty and lets the next read fill it. So the invalidation half of the description above is identical for the two; do not read “cache-aside” as meaning writes skip the cache entirely.
Where the Cache Is Used
Only findOne() reads from the cache
findOne() is the single read path that consults the cache. A builder from find() — and everything built on it, findBy, first, paginate, findAndCountAll, a raw QueryBuilder — executes against the database every time, cache enabled or not. Caching the list queries would mean invalidating every filtered variant on any write, which the library does not attempt.
| Operation | Cache behaviour |
|---|---|
findOne(id) | reads, then populates on a miss — the only reader |
find(), findBy(), first(), paginate(), findAndCountAll(), pluck() | never reads, never populates |
create(), update(), delete(), upsert(), recover(), increment(), decrement() | invalidates the row, then populates it if the strategy is write-through |
bulkCreate(), bulkUpdate(), bulkDelete(), updateBy(), deleteBy(), restoreBy(), seed() | invalidates the whole table |
any of the above inside a transaction() | never cached — reads skip the cache entirely, so a rollback cannot leave a cached copy behind |
Cache Keys
Keys are internal, but the shape explains what a write invalidates:
A findOne called with { relations: [...] } gets its own key, so the same row can be cached twice — once bare and once per distinct relation set. Invalidation accounts for this: a write to a row drops its bare key, every relation variant of it, and the table-wide patterns.
- A single-row write drops
find:<table>, the patternfind:<table>:*, and the patternfindOne:<table>:<id>:* - A bulk write drops
find:<table>and the patternsfind:<table>:*andfindOne:<table>:*— every cached row in the table
Pattern invalidation scans the whole keyspace — Redis KEYS on the shared backend, an equivalent glob walk in process. A bulk write on a busy database is therefore not cheap, and a large keyspace makes it slower still — another reason to keep cachePrefix tight to your application rather than sharing a Redis database.
Cache Statistics
Use orm.getCacheStats() to get cache hit/miss statistics:
const stats = await orm.getCacheStats();
console.log(`Backend: ${stats.backend}`); // "redis" | "memory" | "disabled"console.log(`Cache Hits: ${stats.hits}`);console.log(`Cache Misses: ${stats.misses}`);console.log(`Total Keys: ${stats.keys}`);
// Calculate hit ratioconst total = stats.hits + stats.misses;const ratio = total > 0 ? (stats.hits / total * 100).toFixed(1) : "0";console.log(`Hit Ratio: ${ratio}%`);backend is there so a cache that is doing nothing is distinguishable from one that is merely cold. "disabled" means enabled was false. "memory" means no redisUrl was configured and the cache is confined to this process — which is a fact about the deployment you want to see, not infer from a hit ratio. "redis" reports the configuration, not the connection: ioredis connects lazily and may fail to connect later.
Read the hit ratio with care. Because only findOne() consults the cache, hits and misses count findOne calls alone — a service that lists rows through find() will report a near-zero ratio however well the cache is working. A miss is also only counted when the backend actually answers: if the connection is down, the failure is swallowed and the read falls through to the database without being recorded either way.
orm.healthCheck() names the backend too, rather than reducing it to connected-or-not — an in-process cache has no connection to report:
await orm.healthCheck();// { status: "healthy", database: "postgres", latencyMs: 1.2,// cacheStatus: "in-memory" }cacheStatus is "in-memory" for the in-process backend, "disabled" when caching is off, and "connected" or "connected (miss)" for Redis depending on whether the probe key was found. A round trip that throws surfaces as "unknown".
When the Cache Fails
Cache operations never throw. Every method on the cache catches its own errors, logs them through the ORM logger, and returns the “nothing there” answer: get returns null, set and invalidate do nothing, getStats reports zero keys. A value in the store that cannot be parsed counts as a miss rather than an error, because a value that cannot be returned was not a hit.
That is the right default — a backend outage should degrade to database reads, not to 500s — but it means an unreachable cache is invisible from the application side. Watch the logger, or compare the hit ratio against query volume, rather than assuming a configured cache is a working one.
Configuration Options
interface CacheConfig { enabled: boolean; // Enable/disable caching ttl: number; // Time-to-live in seconds redisUrl?: string; // Shared cache. Omit for the in-process one. cachePrefix?: string; // Key prefix for namespacing. Defaults to "" maxEntries?: number; // In-process LRU bound. Defaults to 1000. strategy?: "cache-aside" | "write-through";}strategydefaults to"cache-aside"when omitted, and an unrecognised value is treated the same way — anything that is not exactly"write-through"is read as cache-aside.cachePrefixdefaults to the empty string, not to the application name, so without it your keys share a namespace with anything else in that Redis database.maxEntriesapplies only to the in-process backend, and defaults to 1000. It is ignored whenredisUrlis set — Redis does its own eviction, and a second, invisible one on top would be worse than none.ttlapplies to reads that populate the cache. Entries written by the write-through path use a fixed 60-second TTL regardless of what you set here.
Cached rows are plaintext
Decryption runs before a row reaches the cache, and caching happens after the row transform, so an encrypted column is stored decrypted — in Redis, or in the process's own memory. See Column Encryption — if a column is encrypted to protect it at rest, the cache has to be treated as sensitive too, or left off for that model.