DEV Community

Arihant Jain
Arihant Jain

Posted on Originally published at arihantjain.cv

Redis Caching in NestJS: I Cut Database Calls by 60% in Production

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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 {}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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)));
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`,
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)