NestJS
13 May 2026
8 MIN READ

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

A practical guide to implementing Redis caching in NestJS with real examples — cache-aside pattern, TTL strategy, cache invalidation, and production lessons.



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.


Building something like this? Hire me to build your backend — NestJS, Node.js, PostgreSQL & Redis, shipped production-ready.

TTL Strategy — How Long to Cache?

Different data needs different TTL:

Data TypeTTLReason
User profile300s (5 min)Changes infrequently
Analytics/stats300s (5 min)Approximate is fine
Post listings60s (1 min)New posts appear quickly
Single post content600s (10 min)Rarely changes after publish
Config/settings3600s (1 hr)Almost never changes
Auth tokensNever cacheSecurity 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:

EndpointBeforeAfterImprovement
GET /analytics800ms8ms99% faster
GET /posts (listing)450ms12ms97% faster
GET /post/:id180ms5ms97% faster
Database queries/min~2,400~96060% 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

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.

Mentioned Technologies

#NestJS#Redis#PostgreSQL#Backend

Frequently Asked Questions

It depends on the workload, but caching frequent read queries can cut database calls dramatically (here by around 60%) and reduce response times from hundreds of milliseconds to single digits.

Cache-aside means the app checks the cache first, and on a miss it loads from the database and writes the result back to the cache for next time.

Use TTLs for data that can be slightly stale, and explicitly invalidate or update cache entries when the underlying data changes to avoid serving outdated values.

Built this in production

SNAPAURA — Digital Social Networking Platform

Social Media Platform — read the case study

Related Reading

Need this built right?

I'm a freelance NestJS & Node.js backend developer. Let's ship your MVP or SaaS.

Hire Me

Ready for more?

Explore other insights in the gallery.

Browse All Posts