A transparent Redis caching layer plugin for Payload CMS v3 that automatically caches database queries to improve performance.
- Automatic Query Caching - Transparently caches all read operations (find, findOne, count, etc.)
- Smart Invalidation - Automatically invalidates cache on write operations (create, update, delete)
- Flexible Configuration - Enable caching per collection or globally with custom TTL
- Per-Request Override - Control cache behavior on individual requests
- Custom Cache Keys - Generate custom cache keys based on your needs
- Pattern-Based Invalidation - Invalidate related cache entries using Redis patterns
- Debug Mode - Optional logging for cache hits, misses, and invalidations
- Zero Breaking Changes - Works seamlessly with existing Payload applications
npm install payloadcms-redis-plugin ioredis
# or
yarn add payloadcms-redis-plugin ioredis
# or
pnpm add payloadcms-redis-plugin ioredis- Payload CMS v3.37.0 or higher
- Node.js 18.20.2+ or 20.9.0+
- Redis server
import { buildConfig } from 'payload'
import { redisCache } from 'payloadcms-redis-plugin'
export default buildConfig({
plugins: [
redisCache({
// Connect via URL
redis: {
url: 'redis://localhost:6379',
},
// Enable caching for specific collections
collections: {
posts: true,
articles: true,
},
}),
],
// ... rest of your config
})import { Redis } from 'ioredis'
import { redisCache } from 'payloadcms-redis-plugin'
const redisClient = new Redis({
host: 'localhost',
port: 6379,
password: 'your-password',
db: 0,
})
export default buildConfig({
plugins: [
redisCache({
// Use existing client
redis: {
client: redisClient,
},
collections: {
posts: true,
},
}),
],
})type RedisPluginConfig = {
// Redis connection (provide either client or url)
redis: { client: Redis; url?: never } | { client?: never; url: string }
// Collections to cache
collections?: Partial<Record<CollectionSlug, CacheOptions | true>>
// Globals to cache
globals?: Partial<Record<GlobalSlug, CacheOptions | true>>
// Enable debug logging
debug?: boolean
// Default cache behavior
defaultCacheOptions?: {
generateKey?: (operation: string, args: DBOperationArgs) => string
keyPrefix?: string
ttl?: number // in seconds, default: 300 (5 minutes)
}
}type CacheOptions = {
key?: string // Custom cache key override
skip?: boolean // Skip cache for this collection/query
tags?: string[] // Tags for grouped invalidation (future feature)
ttl?: number // Time-to-live in seconds
}redisCache({
redis: {
url: process.env.REDIS_URL,
},
// Configure collections with custom TTL
collections: {
posts: {
ttl: 600, // Cache posts for 10 minutes
skip: false,
},
articles: {
ttl: 1800, // Cache articles for 30 minutes
},
users: true, // Use default TTL (5 minutes)
},
// Cache global configurations
globals: {
settings: true,
},
// Custom default options
defaultCacheOptions: {
keyPrefix: 'myapp',
ttl: 300,
generateKey: (operation, args) => {
// Custom key generation logic
const { slug, where, locale } = args
return `${slug}:${operation}:${locale || 'default'}:${JSON.stringify(where)}`
},
},
// Enable debug logging
debug: true,
})Override cache behavior for individual requests:
// Skip cache for a specific query
const freshPosts = await payload.find({
collection: 'posts',
req: {
context: {
cache: {
skip: true, // Bypass cache, always hit database
},
},
},
})
// Custom TTL for a specific query
const shortLivedPosts = await payload.find({
collection: 'posts',
req: {
context: {
cache: {
ttl: 60, // Cache for 1 minute only
},
},
},
})
// Custom cache key
const customCachedPosts = await payload.find({
collection: 'posts',
req: {
context: {
cache: {
key: 'posts:featured',
},
},
},
})The following database operations are automatically cached:
Read Operations (cached before hitting database):
find- Query collections with paginationfindOne- Query single document by IDfindGlobal- Query global configurationsfindGlobalVersions- Query global version historycount- Count documentscountVersions- Count document versionscountGlobalVersions- Count global versionsqueryDrafts- Query draft documents
Write Operations (invalidate cache after database update):
create- Create new documentcreateMany- Batch createupdateOne- Update single documentupdateMany- Batch updatedeleteOne- Delete single documentdeleteMany- Batch deleteupsert- Create or updateupdateGlobal- Update global configupdateGlobalVersion- Update global versiondeleteVersions- Delete document versions
By default, cache keys are generated using MD5 hashing:
[prefix]:[slug]:[operation]:[md5-hash]
The hash includes: { slug, locale, operation, where }
Example keys:
posts:find:a1b2c3d4e5f6g7h8
myapp:articles:count:x9y8z7w6v5u4t3s2
Read Operations:
Request → Check cache config → Check skip flag
↓ (cache enabled)
Check Redis → HIT: Return cached → MISS: Hit DB → Store in Redis → Return
↓ (cache disabled/skipped)
Hit DB directly
Write Operations:
Request → Execute on DB → Get cache config → Check skip flag
↓ (cache enabled)
Invalidate pattern → Return result
↓ (cache disabled/skipped)
Return result directly
When data changes, the plugin automatically invalidates related cache entries using pattern matching:
// Creating a post invalidates all post queries
await payload.create({
collection: 'posts',
data: { title: 'New Post' },
})
// Invalidates: posts:*, myapp:*:posts:*, etc.
// Updating an article invalidates all article queries
await payload.update({
collection: 'articles',
id: '123',
data: { title: 'Updated' },
})
// Invalidates: articles:*, myapp:*:articles:*, etc.Enable debug logging to monitor cache behavior:
redisCache({
redis: { url: 'redis://localhost:6379' },
collections: { posts: true },
debug: true,
})Console output:
[RedisPlugin] [find] [posts] Cache HIT
[RedisPlugin] [find] [articles] Cache MISS
[RedisPlugin] [create] [posts] Invalidating pattern: posts:*
[RedisPlugin] [update] [posts] Cache SKIP (per-request)
The plugin includes full TypeScript definitions and extends Payload's RequestContext type:
declare module 'payload' {
export interface RequestContext {
cache?: {
key?: string
skip?: boolean
tags?: string[]
ttl?: number
}
}
}- Default TTL: 5 minutes (300 seconds)
- Pattern Matching: Uses
redis.keys()for invalidation (consider SCAN in production with large keyspaces) - Silent Failures: Cache errors don't break database queries
- Memory: Monitor Redis memory usage based on your cache strategy
- Expiration: Redis automatically removes expired keys
# Install dependencies
pnpm install
# Run development server
pnpm dev
# Run tests
pnpm test
# Build plugin
pnpm build
# Lint code
pnpm lintredisCache({
redis: { url: process.env.REDIS_URL },
collections: {
products: { ttl: 3600 }, // Cache products for 1 hour
categories: { ttl: 7200 }, // Cache categories for 2 hours
orders: { skip: true }, // Never cache orders
customers: { ttl: 600 }, // Cache customers for 10 minutes
},
globals: {
siteSettings: { ttl: 86400 }, // Cache site settings for 24 hours
},
})redisCache({
redis: { url: process.env.REDIS_URL },
collections: {
posts: { ttl: 1800 }, // Cache posts for 30 minutes
authors: { ttl: 3600 }, // Cache authors for 1 hour
comments: { ttl: 300 }, // Cache comments for 5 minutes
},
defaultCacheOptions: {
keyPrefix: 'blog',
ttl: 600,
},
debug: process.env.NODE_ENV === 'development',
})// Test Redis connection
const redis = new Redis('redis://localhost:6379')
await redis.ping() // Should return 'PONG'- Enable debug mode to see cache behavior
- Verify collection/global is configured for caching
- Check if
skip: trueis set - Ensure Redis server is running and accessible
- Reduce TTL values
- Be selective about which collections to cache
- Monitor Redis memory with
redis-cli info memory - Consider using Redis maxmemory policies
Contributions are welcome! Please see the GitHub repository for issues and pull requests.
MIT
Isaiah Anyimi pls hire me