Cache
The Cache plugin provides intelligent caching for your CRUD operations to improve performance and reduce database load. It supports multiple cache strategies, TTL-based expiration, and automatic cache invalidation on data changes.
Installation
Add the plugin to your query config
To use the Cache plugin, add it to your query config.
import { betterQuery } from "better-query"
import { cachePlugin } from "better-query/plugins"
export const query = betterQuery({
// ... other config options
plugins: [
cachePlugin({
enabled: true,
defaultTTL: 300, // 5 minutes default TTL
storage: "memory", // or "redis", "custom"
strategies: {
read: "cache-first", // Cache strategy for read operations
list: "cache-first", // Cache strategy for list operations
}
})
]
})Add the client plugin
The cache plugin works automatically on the server side. No additional client configuration is required - caching is handled transparently for all your resource operations.
import { createQueryClient } from "better-query/client"
export const queryClient = createQueryClient({
baseURL: "http://localhost:3000/api"
})
// All operations automatically benefit from server-side caching
const products = await queryClient.product.list();Cache Strategies
The cache plugin supports different caching strategies:
cache-first: Check cache first, fallback to database if not foundnetwork-first: Check database first, fallback to cache if error occurscache-only: Only use cached data (throws error if not cached)network-only: Always fetch from database (but still updates cache)no-cache: Disable caching for specific operations
Configuration
Global Cache Settings
Configure cache behavior globally:
cachePlugin({
/**
* Enable/disable caching
*/
enabled: true,
/**
* Default TTL in seconds for all cached items
*/
defaultTTL: 300,
/**
* Cache storage backend
*/
storage: "memory", // "memory" | "redis" | "custom"
/**
* Redis configuration (if using Redis storage)
*/
redis: {
host: "localhost",
port: 6379,
password: process.env.REDIS_PASSWORD,
},
/**
* Global cache strategies
*/
strategies: {
read: "cache-first",
list: "cache-first",
create: "network-only", // Creates should always hit DB
update: "network-only", // Updates should always hit DB
delete: "network-only", // Deletes should always hit DB
},
/**
* Resource-specific cache settings
*/
resources: {
product: {
readTTL: 600, // 10 minutes for product reads
listTTL: 300, // 5 minutes for product lists
strategy: "cache-first",
},
category: {
readTTL: 1800, // 30 minutes (categories change less frequently)
listTTL: 900, // 15 minutes
strategy: "cache-first",
},
user: {
readTTL: 120, // 2 minutes (user data changes more frequently)
listTTL: 60, // 1 minute
strategy: "network-first",
}
},
/**
* Cache key generation function
*/
keyGenerator: (operation, resourceType, resourceId, params) => {
const base = `bq:${resourceType}:${operation}`
if (resourceId) return `${base}:${resourceId}`
if (params) return `${base}:${JSON.stringify(params)}`
return base
},
/**
* Custom cache invalidation rules
*/
invalidation: {
// When a product is updated, invalidate related caches
product: {
update: ["product:read", "product:list", "category:list"],
delete: ["product:read", "product:list", "category:list"],
},
category: {
update: ["category:read", "category:list", "product:list"],
delete: ["category:read", "category:list", "product:list"],
}
}
})Resource-Level Cache Configuration
Configure caching per resource:
import { betterQuery, createResource } from "better-query"
export const query = betterQuery({
plugins: [cachePlugin()],
resources: [
createResource({
name: "product",
schema: productSchema,
cache: {
enabled: true,
strategies: {
read: "cache-first",
list: "cache-first",
},
ttl: {
read: 600, // 10 minutes
list: 300, // 5 minutes
},
invalidateOn: ["update", "delete"], // Auto-invalidate cache on these operations
}
}),
createResource({
name: "order",
schema: orderSchema,
cache: {
enabled: false, // Disable caching for orders (sensitive data)
}
})
]
})Usage
Automatic Caching
With the plugin configured, caching happens automatically based on your strategy settings:
// This will automatically use cache-first strategy if configured
const product = await queryClient.product.read("product-123")
// This will automatically cache the list results
const products = await queryClient.product.list({ page: 1, limit: 10 })Manual Cache Control
For more granular control, you can override cache behavior per operation:
// Force a fresh fetch from database, bypassing cache
const freshProduct = await queryClient.product.read("product-123", {
cache: {
strategy: "network-only"
}
})
// Use cached data only, throw error if not in cache
const cachedProduct = await queryClient.product.read("product-123", {
cache: {
strategy: "cache-only"
}
})
// Set custom TTL for this specific request
const productWithCustomTTL = await queryClient.product.read("product-123", {
cache: {
strategy: "cache-first",
ttl: 1800 // 30 minutes
}
})Cache Management
The plugin provides methods for manual cache management:
const { data: invalidationResult, error } = await authClient.cache.invalidate({ resourceType: "product", resourceId: "product-123", operation: "all", pattern: "bq:product:*",});| Prop | Description | Type |
|---|---|---|
resourceType? | Resource type to invalidate | string |
resourceId? | Specific resource ID to invalidate | string |
operation? | Operation type to invalidate | "read" | "list" | "all" |
pattern? | Cache key pattern to match | string |
Client Usage:
import { queryClient } from "@/lib/query-client"
// Invalidate all caches for a specific resource
await queryClient.cache.invalidate({
resourceType: "product",
operation: "all"
})
// Invalidate cache for a specific resource item
await queryClient.cache.invalidate({
resourceType: "product",
resourceId: "product-123"
})
// Invalidate using pattern matching
await queryClient.cache.invalidate({
pattern: "bq:product:list:*" // Invalidate all product list caches
})
// Clear all caches
await queryClient.cache.clear()
// Get cache statistics
const stats = await queryClient.cache.getStats()
console.log(stats.data)
// {
// totalKeys: 156,
// hitRate: 0.85,
// missRate: 0.15,
// size: "2.4 MB",
// byResource: {
// product: { keys: 78, hits: 234, misses: 45 },
// category: { keys: 23, hits: 89, misses: 12 }
// }
// }Warm-up Cache
Pre-populate cache with frequently accessed data:
// Warm up product caches
await queryClient.cache.warmup("product", {
operations: ["list"], // Pre-cache product lists
params: [
{ page: 1, limit: 20 }, // First page
{ page: 1, limit: 20, status: "active" }, // Active products
{ page: 1, limit: 20, featured: true }, // Featured products
]
})
// Warm up specific product reads
await queryClient.cache.warmup("product", {
operations: ["read"],
resourceIds: ["product-1", "product-2", "product-3"] // Popular products
})Custom Cache Storage
Implement custom cache storage backend:
import { CacheStorage } from "better-query/plugins"
class CustomCacheStorage implements CacheStorage {
private cache = new Map<string, { value: any; expiry: number }>()
async get(key: string): Promise<any> {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() > item.expiry) {
this.cache.delete(key)
return null
}
return item.value
}
async set(key: string, value: any, ttl: number): Promise<void> {
const expiry = Date.now() + (ttl * 1000)
this.cache.set(key, { value, expiry })
}
async del(key: string): Promise<void> {
this.cache.delete(key)
}
async clear(): Promise<void> {
this.cache.clear()
}
async keys(pattern?: string): Promise<string[]> {
if (!pattern) return Array.from(this.cache.keys())
const regex = new RegExp(pattern.replace(/\*/g, '.*'))
return Array.from(this.cache.keys()).filter(key => regex.test(key))
}
async size(): Promise<number> {
return this.cache.size
}
}
// Use custom storage
cachePlugin({
storage: "custom",
customStorage: new CustomCacheStorage()
})React Hooks
Use caching with React hooks for better UX:
import { useState, useEffect } from 'react'
import { queryClient } from '@/lib/query-client'
export function useCachedResource<T>(resourceType: string, resourceId: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(false)
const [fromCache, setFromCache] = useState(false)
useEffect(() => {
const fetchResource = async () => {
setLoading(true)
// Try cache first
try {
const cachedResult = await queryClient[resourceType].read(resourceId, {
cache: { strategy: "cache-only" }
})
if (cachedResult.data) {
setData(cachedResult.data)
setFromCache(true)
setLoading(false)
// Fetch fresh data in background to update cache
queryClient[resourceType].read(resourceId, {
cache: { strategy: "network-first" }
}).then(freshResult => {
if (freshResult.data &&
JSON.stringify(freshResult.data) !== JSON.stringify(cachedResult.data)) {
setData(freshResult.data)
}
})
return
}
} catch (error) {
// Cache miss, continue to network
}
// Fetch from network
const result = await queryClient[resourceType].read(resourceId)
if (result.data) {
setData(result.data)
setFromCache(false)
}
setLoading(false)
}
if (resourceType && resourceId) {
fetchResource()
}
}, [resourceType, resourceId])
const refresh = async (force = false) => {
const strategy = force ? "network-only" : "network-first"
setLoading(true)
const result = await queryClient[resourceType].read(resourceId, {
cache: { strategy }
})
if (result.data) {
setData(result.data)
setFromCache(false)
}
setLoading(false)
}
return {
data,
loading,
fromCache,
refresh
}
}Usage in components:
import { useCachedResource } from '@/hooks/use-cached-resource'
export function ProductDetails({ productId }: { productId: string }) {
const { data: product, loading, fromCache, refresh } = useCachedResource('product', productId)
return (
<div>
{loading && <div>Loading...</div>}
{product && (
<div>
<h1>{product.name}</h1>
<p>Price: ${product.price}</p>
{fromCache && (
<p className="text-sm text-gray-500">
Loaded from cache
<button onClick={() => refresh(true)} className="ml-2 text-blue-500">
Refresh
</button>
</p>
)}
</div>
)}
</div>
)
}Performance Monitoring
Monitor cache performance and optimize based on metrics:
import { queryClient } from '@/lib/query-client'
// Get detailed cache statistics
const cacheStats = await queryClient.cache.getStats()
console.log("Cache Performance Report:")
console.log(`Overall Hit Rate: ${(cacheStats.data.hitRate * 100).toFixed(1)}%`)
console.log(`Total Cache Size: ${cacheStats.data.size}`)
// Resource-specific performance
Object.entries(cacheStats.data.byResource).forEach(([resource, stats]) => {
const hitRate = stats.hits / (stats.hits + stats.misses)
console.log(`${resource}: ${(hitRate * 100).toFixed(1)}% hit rate (${stats.keys} keys)`)
})
// Set up cache monitoring (in production)
if (process.env.NODE_ENV === 'production') {
setInterval(async () => {
const stats = await queryClient.cache.getStats()
// Send metrics to monitoring service
analytics.track('cache_performance', {
hitRate: stats.data.hitRate,
totalKeys: stats.data.totalKeys,
cacheSize: stats.data.size,
})
// Alert if hit rate is too low
if (stats.data.hitRate < 0.7) {
console.warn('Cache hit rate is below 70%, consider optimizing cache strategies')
}
}, 60000) // Check every minute
}Best Practices
-
TTL Selection: Set appropriate TTL based on data change frequency
// Fast-changing data: shorter TTL user: { readTTL: 60 } // Slow-changing data: longer TTL category: { readTTL: 1800 } -
Cache Invalidation: Set up proper invalidation rules
invalidation: { product: { update: ["product:read", "product:list", "category:list"], } } -
Memory Management: Monitor cache size and implement eviction policies
cachePlugin({ maxSize: "100MB", // Limit cache size evictionPolicy: "lru", // Least recently used }) -
Selective Caching: Don't cache sensitive or frequently changing data
// Disable caching for sensitive resources order: { cache: { enabled: false } }