Caching
Built-in caching for read queries with automatic invalidation and stale-while-revalidate support
Features
- Multiple Backends - In-memory, Cloudflare KV, or custom implementations
- Automatic Invalidation - Optionally clear cache when data changes
- Stale-While-Revalidate - Return stale data instantly while refreshing in background
- Serverless Ready - Works with Cloudflare Workers and Vercel Edge
- Type-Safe - Full TypeScript support, only read operations available on cached client
- Cache Versioning - Invalidate all cache on schema changes
- OpenTelemetry - Built-in tracing with hit/miss/stale status
Quick Start
import { createClient, MemoryCache } from "viborm";
const client = createClient({
schema: { user, post },
driver,
cache: new MemoryCache(),
});
// Use cached queries
const users = await client.$withCache().user.findMany({
where: { active: true },
});Configuration
Setting Up a Cache Driver
Pass a cache driver when creating the client:
import { MemoryCache, CloudflareKVCache } from "viborm";
// In-memory cache (development/single instance)
const client = createClient({
schema,
driver,
cache: new MemoryCache(),
});
// Cloudflare KV cache (production/distributed)
const client = createClient({
schema,
driver,
cache: new CloudflareKVCache(env.MY_KV_NAMESPACE),
});Cache Versioning
Use cacheVersion to invalidate all cached data when your schema changes:
const client = createClient({
schema,
driver,
cache: new MemoryCache(),
cacheVersion: 2, // Bump when schema changes
});Cache keys become versioned: viborm:v2:user:findMany:...
When you update the version, all previously cached entries are automatically ignored (different key prefix). This prevents stale data with incompatible shapes from being returned after schema migrations.
Using $withCache
The $withCache() method returns a cached client that only exposes read operations:
// Default: 5 minute TTL
const cached = client.$withCache();
// Custom TTL
const cached = client.$withCache({ ttl: "1 hour" });
const cached = client.$withCache({ ttl: 30000 }); // 30 seconds in ms
// With stale-while-revalidate
const cached = client.$withCache({ ttl: "5 minutes", swr: true });
// Force fresh fetch (bypass cache read)
const fresh = client.$withCache({ bypass: true });Available Options
| Option | Type | Default | Description |
|---|---|---|---|
ttl | string | number | 300000 (5 min) | Cache duration. String format: "1 hour", "30 seconds", etc. |
swr | boolean | string | number | false | Enable stale-while-revalidate. When true, uses 2x TTL. Can also specify a custom SWR window duration. |
key | string | auto-generated | Custom cache key (overrides automatic key generation) |
bypass | boolean | false | Skip cache read and force fresh fetch (still writes to cache) |
TTL Format
TTL can be specified as milliseconds or human-readable strings:
// Milliseconds
{ ttl: 60000 } // 1 minute
// Human-readable strings
{ ttl: "30 seconds" }
{ ttl: "5 minutes" }
{ ttl: "1 hour" }
{ ttl: "2 hours" }
{ ttl: "1 day" }
{ ttl: "1 week" }Supported units: ms, s/sec/second/seconds, m/min/minute/minutes, h/hr/hour/hours, d/day/days, w/week/weeks, month/months
Cached Operations
Only read operations are available on the cached client:
const cached = client.$withCache({ ttl: "1 hour" });
// Available operations
await cached.user.findMany({ where: { active: true } });
await cached.user.findFirst({ where: { role: "admin" } });
await cached.user.findUnique({ where: { id: "123" } });
await cached.user.findUniqueOrThrow({ where: { id: "123" } });
await cached.user.findFirstOrThrow({ where: { role: "admin" } });
await cached.user.count({ where: { active: true } });
await cached.user.aggregate({ _avg: { age: true } });
await cached.user.groupBy({ by: ["role"], _count: true });
await cached.user.exist({ where: { email: "test@example.com" } });
// Mutation operations are NOT available on cached client
// cached.user.create() // TypeScript error!
// cached.user.update() // TypeScript error!Stale-While-Revalidate (SWR)
SWR improves perceived performance by returning cached data immediately while refreshing in the background:
const cached = client.$withCache({ ttl: "5 minutes", swr: true });
// First request: cache miss, executes query
const users1 = await cached.user.findMany(); // ~50ms (database query)
// Second request (within TTL): fresh cache hit
const users2 = await cached.user.findMany(); // ~1ms (from cache)
// Third request (after TTL but within 2x TTL): stale hit + background refresh
const users3 = await cached.user.findMany(); // ~1ms (stale data returned)
// Background: query executes and updates cacheHow SWR Works
- Fresh hit (age < TTL): Return cached data immediately
- Stale hit (TTL < age < SWR window): Return stale data, refresh in background
- Miss (age > SWR window or not cached): Execute query, cache result
By default, the SWR window is 2× the TTL. You can customize this:
// Default: SWR window is 2x TTL (10 minutes total storage)
const cached = client.$withCache({ ttl: "5 minutes", swr: true });
// Custom SWR window of 1 hour (stored for 1 hour total)
const cached = client.$withCache({ ttl: "5 minutes", swr: "1 hour" });
// Custom SWR window in milliseconds
const cached = client.$withCache({ ttl: 300000, swr: 3600000 });Serverless Environments
In serverless environments (Cloudflare Workers, Vercel Edge), configure waitUntil at the client level to ensure background revalidation completes:
// Cloudflare Workers
export default {
async fetch(request, env, ctx) {
const client = createClient({
schema: { user },
driver: createD1Driver(env.DB),
cache: new CloudflareKVCache(env.CACHE),
waitUntil: ctx.waitUntil.bind(ctx),
});
const users = await client
.$withCache({ ttl: "5 minutes", swr: true })
.user.findMany();
return Response.json(users);
}
}
// Vercel Edge
import { waitUntil } from "@vercel/functions";
const client = createClient({
schema: { user },
driver,
cache: new MemoryCache(),
waitUntil,
});
export async function GET() {
const users = await client
.$withCache({ ttl: "5 minutes", swr: true })
.user.findMany();
return Response.json(users);
}Without waitUntil in serverless environments, background revalidation may be terminated when the request completes.
Cache Invalidation
Direct Invalidation with $invalidate
Use $invalidate to manually invalidate cache entries at any time:
// Invalidate specific keys
await client.$invalidate("user:findMany:abc123");
// Invalidate by prefix (use * suffix)
await client.$invalidate("user:*");
// Invalidate multiple patterns
await client.$invalidate("user:*", "post:findMany:*");When cacheVersion is configured, $invalidate automatically uses the versioned prefix. For example, with cacheVersion: 2, calling $invalidate("user:*") clears viborm:v2:user:*.
Invalidation on Mutations
Specify cache keys or prefixes to invalidate when performing mutations:
// Invalidate specific keys
await client.user.update({
where: { id: "123" },
data: { name: "Alice" },
cache: {
invalidate: ["user:findUnique:abc123"],
},
});
// Invalidate by prefix (note the * suffix)
await client.user.update({
where: { id: "123" },
data: { name: "Alice" },
cache: {
invalidate: ["user:findMany:*"], // Clears all findMany caches for user
},
});
// Invalidate multiple patterns
await client.post.create({
data: { title: "New Post", authorId: "123" },
cache: {
invalidate: [
"post:*", // All post caches
"user:123:*", // All caches for this user
],
},
});Automatic Model Invalidation
Enable autoInvalidate to automatically clear all cache entries for a model after mutations:
await client.user.update({
where: { id: "123" },
data: { name: "Alice" },
cache: {
autoInvalidate: true, // Clears all user:* cache entries
},
});Auto-invalidation clears ALL cached queries for the model. Use manual invalidation for more granular control.
Cache Key Generation
Cache keys are automatically generated from the model name, operation, and query arguments:
viborm[:v<version>]:<model>:<operation>:<hash>For example:
viborm:user:findMany:a1b2c3d4e5f6...
viborm:v2:user:findMany:a1b2c3d4e5f6... // with cacheVersion: 2The hash is deterministic — identical queries always produce the same key, regardless of object property order.
The current key format includes the operation name, which means findFirst and findUnique with identical arguments will have separate cache entries even though they may produce the same SQL query. Use custom cache keys if you need to share cache entries across operations.
Custom Cache Keys
Override automatic key generation when needed:
const cached = client.$withCache({
ttl: "1 hour",
key: "homepage-featured-users",
});
const users = await cached.user.findMany({
where: { featured: true },
take: 5,
});Generating Keys for Manual Invalidation
Use generateCacheKey to create keys matching the auto-generated format:
import { generateCacheKey } from "viborm";
// Generate the same key that would be used for a cached query
const key = generateCacheKey(
"user",
"findMany",
{ where: { active: true } },
2 // optional: cacheVersion
);
// "viborm:v2:user:findMany:abc123..."
// Use for precise invalidation
await client.$invalidate(key);Observability
Cache operations emit OpenTelemetry spans when instrumentation is configured.
Spans
| Span Name | Description |
|---|---|
viborm.cache.get | Cache read operation |
viborm.cache.set | Cache write operation |
viborm.cache.delete | Cache key deletion |
viborm.cache.clear | Cache prefix clearing |
viborm.cache.invalidate | Cache invalidation |
Attributes
| Attribute | Description |
|---|---|
cache.driver | Driver name (memory, cloudflare-kv, etc.) |
cache.key | Cache key being accessed |
cache.result | Result of cache read: hit, miss, stale, or bypass |
cache.ttl | TTL in milliseconds |
Example Trace
viborm.operation (user.findMany)
└── viborm.cache.get (cache.result: "hit")When SWR returns stale data:
viborm.operation (user.findMany)
└── viborm.cache.get (cache.result: "stale")
└── [background] viborm.cache.setCache Drivers
VibORM includes built-in cache drivers and supports custom implementations.
| Driver | Best For | Persistence |
|---|---|---|
| MemoryCache | Development, testing | No |
| CloudflareKVCache | Cloudflare Workers | Yes |
| Custom | Redis, Upstash, etc. | Varies |
See Cache Drivers for detailed configuration and implementation guides.
Examples
Basic Caching
const client = createClient({
schema: { user, post },
driver,
cache: new MemoryCache(),
});
// Cache user queries for 10 minutes
const cached = client.$withCache({ ttl: "10 minutes" });
const users = await cached.user.findMany({
where: { active: true },
include: { posts: true },
});SWR with Cloudflare Workers
import { createClient, CloudflareKVCache } from "viborm";
export default {
async fetch(request, env, ctx) {
const client = createClient({
schema: { user },
driver: createD1Driver(env.DB),
cache: new CloudflareKVCache(env.CACHE),
waitUntil: ctx.waitUntil.bind(ctx),
});
const users = await client
.$withCache({ ttl: "5 minutes", swr: true })
.user.findMany({ where: { active: true } });
return Response.json(users);
}
}Invalidation on Write
// Create a post and invalidate related caches
await client.post.create({
data: {
title: "New Post",
authorId: userId,
},
cache: {
invalidate: [
"post:findMany:*", // All post lists
`user:${userId}:posts:*`, // This user's post caches
],
},
});Different TTLs for Different Queries
// Short TTL for frequently changing data
const recentPosts = await client
.$withCache({ ttl: "1 minute" })
.post.findMany({
orderBy: { createdAt: "desc" },
take: 10,
});
// Longer TTL for stable data
const categories = await client
.$withCache({ ttl: "1 hour" })
.category.findMany();
// SWR for user-facing pages
const featuredUsers = await client
.$withCache({ ttl: "5 minutes", swr: true })
.user.findMany({
where: { featured: true },
});