Problem Statement
On one of my freelance projects — a creative portfolio app — the analytics endpoint was getting hammered.
Every page load triggered the same expensive PostgreSQL aggregation query:
SELECT
COUNT(*) FILTER (WHERE status = 'published') as published,
COUNT(*) FILTER (WHERE status = 'draft') as drafts,
COUNT(DISTINCT viewer_id) as unique_views
FROM posts
WHERE user_id = $1;
This query ran on every single request. For a user with 500+ posts, it took 800-1200ms every time.
The data changed maybe once every few minutes. Yet we were recalculating it thousands of times per hour.
Redis fixed this in under an hour of work.
Setup
npm install @nestjs/cache-manager cache-manager cache-manager-redis-store redis
// app.module.ts
import { CacheModule } from "@nestjs/cache-manager";
import { redisStore } from "cache-manager-redis-store";
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await redisStore({
socket: {
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT) || 6379,
},
ttl: 300, // default 5 minutes
}),
}),
}),
],
})
export class AppModule {}
The Cache-Aside Pattern
This is the pattern I use on every project. Simple, predictable, battle-tested.
Request comes in
↓
Check Redis for cached value
↓
Cache HIT? → return cached data immediately (fast)
Cache MISS? → query database → store in Redis → return data
// src/cache/cache.service.ts
import { Injectable, Inject } from "@nestjs/common";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
@Injectable()
export class CacheService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 300,
): Promise<T> {
// Check cache first
const cached = await this.cache.get<T>(key);
if (cached) return cached;
// Cache miss — fetch from source
const data = await fetcher();
// Store in cache
await this.cache.set(key, data, ttl);
return data;
}
async invalidate(key: string): Promise<void> {
await this.cache.del(key);
}
async invalidatePattern(keys: string[]): Promise<void> {
await Promise.all(keys.map((key) => this.cache.del(key)));
}
}
Clean, reusable, and works anywhere in your app.
Real Implementation — Analytics Endpoint
// analytics.service.ts
@Injectable()
export class AnalyticsService {
constructor(
private prisma: PrismaService,
private cacheService: CacheService,
) {}
async getUserStats(userId: string) {
const cacheKey = `analytics:user:${userId}`;
return this.cacheService.getOrSet(
cacheKey,
async () => {
// This expensive query only runs on cache miss
const [published, drafts, totalViews] = await Promise.all([
this.prisma.post.count({
where: { userId, status: "published" },
}),
this.prisma.post.count({
where: { userId, status: "draft" },
}),
this.prisma.postView.count({
where: { post: { userId } },
}),
]);
return { published, drafts, totalViews };
},
300, // cache for 5 minutes
);
}
}
Result: Analytics endpoint dropped from 800ms to 8ms on cache hit. A 99% reduction.
Cache Invalidation — The Hard Part
Caching is easy. Knowing when to invalidate is where most developers make mistakes.
Rule: invalidate cache when the underlying data changes.
// posts.service.ts
@Injectable()
export class PostsService {
constructor(
private prisma: PrismaService,
private cacheService: CacheService,
) {}
async createPost(userId: string, dto: CreatePostDto) {
const post = await this.prisma.post.create({
data: { ...dto, userId },
});
// Data changed — invalidate analytics cache
await this.cacheService.invalidate(`analytics:user:${userId}`);
return post;
}
async updatePost(id: string, userId: string, dto: UpdatePostDto) {
const post = await this.prisma.post.update({
where: { id, userId },
data: dto,
});
// Invalidate both the specific post cache and the list cache
await this.cacheService.invalidatePattern([
`post:${id}`,
`posts:user:${userId}`,
`analytics:user:${userId}`,
]);
return post;
}
async deletePost(id: string, userId: string) {
await this.prisma.post.delete({ where: { id, userId } });
await this.cacheService.invalidatePattern([
`post:${id}`,
`posts:user:${userId}`,
`analytics:user:${userId}`,
]);
}
}
Cache Key Strategy
Consistent, predictable cache keys prevent bugs and make debugging easier.
// Use a centralized key builder
export const CacheKeys = {
// Single resource
post: (id: string) => `post:${id}`,
user: (id: string) => `user:${id}`,
// Lists
userPosts: (userId: string, page: number) =>
`posts:user:${userId}:page:${page}`,
// Analytics
userAnalytics: (userId: string) => `analytics:user:${userId}`,
// Tenant-scoped (for multi-tenant apps)
tenantProducts: (tenantId: string, page: number) =>
`products:tenant:${tenantId}:page:${page}`,
};
// Usage
const data = await this.cacheService.getOrSet(
CacheKeys.userAnalytics(userId),
() => this.computeAnalytics(userId),
300,
);
No more hardcoded strings scattered across your codebase.
TTL Strategy — How Long to Cache?
Different data needs different TTL:
| Data Type | TTL | Reason |
|---|---|---|
| User profile | 300s (5 min) | Changes infrequently |
| Analytics/stats | 300s (5 min) | Approximate is fine |
| Post listings | 60s (1 min) | New posts appear quickly |
| Single post content | 600s (10 min) | Rarely changes after publish |
| Config/settings | 3600s (1 hr) | Almost never changes |
| Auth tokens | Never cache | Security risk |
// TTL constants — never hardcode numbers
export const CacheTTL = {
SHORT: 60, // 1 minute — frequently changing data
MEDIUM: 300, // 5 minutes — standard cache
LONG: 600, // 10 minutes — stable content
EXTENDED: 3600, // 1 hour — rarely changes
} as const;
// Usage
await this.cacheService.getOrSet(key, fetcher, CacheTTL.MEDIUM);
Caching Paginated Results
Pagination is a common caching mistake — developers cache the whole list instead of per-page.
// ❌ Wrong — caches all posts, ignores pagination
async getPosts(userId: string, page: number) {
return this.cacheService.getOrSet(
`posts:user:${userId}`, // same key for all pages!
() => this.prisma.post.findMany({ skip: (page-1)*10, take: 10 }),
300,
);
}
// ✅ Correct — separate cache per page
async getPosts(userId: string, page: number, limit: number) {
return this.cacheService.getOrSet(
`posts:user:${userId}:page:${page}:limit:${limit}`,
async () => {
const [posts, total] = await Promise.all([
this.prisma.post.findMany({
where: { userId },
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.post.count({ where: { userId } }),
]);
return { posts, total, page, limit };
},
CacheTTL.SHORT, // shorter TTL for lists — content changes more
);
}
What NOT to Cache
Some things should never be cached:
// ❌ Never cache authentication
async validateToken(token: string) {
// Always hit the database — stale auth data is a security risk
return this.prisma.session.findUnique({ where: { token } });
}
// ❌ Never cache user-specific sensitive data with shared keys
// This would leak user A's data to user B if key collision occurs
await this.cache.set('user-data', sensitiveData); // missing userId in key!
// ❌ Never cache write operations
async createOrder(dto: CreateOrderDto) {
// Always write to DB immediately — never cache writes
return this.prisma.order.create({ data: dto });
}
// ✅ Cache reads, never writes
// ✅ Always include userId/tenantId in cache keys for user-scoped data
// ✅ Never cache authentication or authorization data
Monitoring Cache Performance
Add cache hit/miss logging to measure effectiveness:
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 300,
): Promise<T> {
const cached = await this.cache.get<T>(key);
if (cached) {
// Log cache hit in production for monitoring
this.logger.debug(`Cache HIT: ${key}`);
return cached;
}
this.logger.debug(`Cache MISS: ${key}`);
const start = Date.now();
const data = await fetcher();
const duration = Date.now() - start;
// Log slow DB queries
if (duration > 500) {
this.logger.warn(`Slow query cached: ${key} took ${duration}ms`);
}
await this.cache.set(key, data, ttl);
return data;
}
Check your logs after deploying. A healthy cache should show 70-90% hit rate on frequently accessed endpoints.
Results After Implementing Redis Cache
Here's what changed on the portfolio platform:
| Endpoint | Before | After | Improvement |
|---|---|---|---|
GET /analytics |
800ms | 8ms | 99% faster |
GET /posts (listing) |
450ms | 12ms | 97% faster |
GET /post/:id |
180ms | 5ms | 97% faster |
| Database queries/min | ~2,400 | ~960 | 60% reduction |
The 60% reduction in database calls directly reduced PostgreSQL CPU usage and allowed the same server to handle significantly more concurrent users without scaling.
Quick Wins Checklist
Before adding Redis to any NestJS project, identify:
✅ Endpoints called frequently with the same parameters
✅ Expensive aggregation or JOIN queries
✅ Data that doesn't change on every request
✅ Public data (leaderboards, stats, featured content)
✅ Configuration or settings loaded repeatedly
❌ User authentication and sessions
❌ Real-time data (prices, live scores)
❌ Write operations
❌ Highly personalized data with complex cache keys
Internal Links
This caching layer works hand-in-hand with the queue-based architecture I described in Building a Dynamic Notification Engine with BullMQ and Redis — both use Redis but for completely different purposes.
For the full backend stack this fits into, see NestJS + Prisma + PostgreSQL: My Freelance Backend Stack.
Need this implemented for your project? Let's talk.
Have questions about Redis caching patterns? Drop a message — I'd love to help.
Top comments (0)