Skip to content
NestJS ns interceptors 4 min read

Caching Interceptor

Interceptors are unique among NestJS request-pipeline constructs because they can decide whether the route handler runs at all. Since intercept controls when (or if) next.handle() is called, an interceptor can check a cache and, on a hit, return a synthetic Observable of the stored value without ever invoking the handler. This page builds a caching interceptor by hand to understand the mechanics, then shows how the official @nestjs/cache-manager package packages the same idea for production use.

Why interceptors can short-circuit

The signature of intercept(context, next) hands you a CallHandler rather than the handler’s result directly. Nothing forces you to call next.handle(). If you can produce a response some other way — from a cache, a feature flag, or a precomputed value — you simply return an Observable of it. The handler, its services, and any database calls inside it are skipped entirely.

RxJS of(value) is the tool for this. It creates an Observable that immediately emits value and completes, which is exactly what downstream operators (and Nest’s response serializer) expect from next.handle(). This makes a cache hit indistinguishable from a real handler response to the rest of the pipeline.

A hand-rolled caching interceptor

The interceptor below caches GET responses keyed by URL. On a hit it returns of(cached) to short-circuit; on a miss it lets the handler run and uses tap to store the freshly produced value with a TTL.

// src/common/interceptors/caching.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Observable, of, tap } from 'rxjs';
import { Request } from 'express';

interface CacheEntry {
  value: unknown;
  expiresAt: number;
}

@Injectable()
export class CachingInterceptor implements NestInterceptor {
  private readonly store = new Map<string, CacheEntry>();
  private readonly ttlMs = 5_000;

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const request = context.switchToHttp().getRequest<Request>();

    // Only cache idempotent reads.
    if (request.method !== 'GET') {
      return next.handle();
    }

    const key = request.url;
    const entry = this.store.get(key);

    if (entry && entry.expiresAt > Date.now()) {
      // Cache hit: skip the handler completely.
      return of(entry.value);
    }

    // Cache miss: run the handler, then store its result.
    return next.handle().pipe(
      tap((value) => {
        this.store.set(key, {
          value,
          expiresAt: Date.now() + this.ttlMs,
        });
      }),
    );
  }
}

The if (entry && entry.expiresAt > Date.now()) branch never touches next, so the wrapped handler is bypassed. Every other path returns the genuine handler stream, with tap recording the value as a side effect without altering it.

Seeing the short-circuit in action

Bind the interceptor to a controller whose handler is deliberately slow, so a cache hit is obvious from the response timing.

// src/products/products.controller.ts
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CachingInterceptor } from '../common/interceptors/caching.interceptor';

@Controller('products')
@UseInterceptors(CachingInterceptor)
export class ProductsController {
  @Get('featured')
  async getFeatured() {
    // Simulate an expensive query.
    await new Promise((resolve) => setTimeout(resolve, 800));
    return { items: ['camera', 'lens'], generatedAt: Date.now() };
  }
}

The first request pays the 800ms cost; the second, within the TTL window, returns instantly with the identical generatedAt value — proof the handler did not re-run.

time curl -s http://localhost:3000/products/featured
time curl -s http://localhost:3000/products/featured

Output:

{"items":["camera","lens"],"generatedAt":1749897662001}
real    0m0.832s
{"items":["camera","lens"],"generatedAt":1749897662001}
real    0m0.009s

Using @nestjs/cache-manager

For real applications, reach for the official package instead of a hand-rolled Map. It ships a CacheInterceptor that auto-caches GET responses and a CacheModule backed by pluggable stores (in-memory, Redis, and more).

npm install @nestjs/cache-manager cache-manager
// src/app.module.ts
import { Module } from '@nestjs/common';
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ProductsModule } from './products/products.module';

@Module({
  imports: [
    CacheModule.register({ ttl: 5_000, max: 100, isGlobal: true }),
    ProductsModule,
  ],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: CacheInterceptor,
    },
  ],
})
export class AppModule {}

You can tune per-handler behaviour with decorators. @CacheKey overrides the auto-generated key and @CacheTTL sets a route-specific lifetime.

// src/products/products.controller.ts
import { Controller, Get } from '@nestjs/common';
import { CacheKey, CacheTTL } from '@nestjs/cache-manager';

@Controller('products')
export class ProductsController {
  @Get('featured')
  @CacheKey('featured-products')
  @CacheTTL(10_000)
  getFeatured() {
    return { items: ['camera', 'lens'] };
  }
}
ConcernHand-rolled interceptor@nestjs/cache-manager
Storage backendManual MapPluggable (memory, Redis, etc.)
Key strategyCustom logicURL by default; @CacheKey to override
TTL controlHardcoded fieldCacheModule default + @CacheTTL
Eviction / size limitYou implement itmax option, LRU eviction
Tracking controlAlways ontrackBy override per request

Tip: The built-in CacheInterceptor only caches GET requests by default. To cache other methods, or to exclude specific routes, subclass it and override isRequestCacheable() or trackBy().

Gotcha: Never cache responses that depend on the authenticated user with a URL-only key — two different users hitting the same path would share a cache entry. Override trackBy() to fold the user id (or an auth header) into the key for per-user responses.

Best practices

  • Restrict caching to idempotent reads (GET) and explicitly bypass mutating methods so writes always hit the handler.
  • Use of(value) to short-circuit so a cache hit looks identical to a real response to downstream operators and serializers.
  • Prefer @nestjs/cache-manager over a bespoke Map in production — it gives you TTL, size limits, eviction, and Redis-backed sharing across instances.
  • Include all request dimensions that affect the response (user, query params, headers) in the cache key to avoid serving one client’s data to another.
  • Keep TTLs short for volatile data and add explicit invalidation on writes rather than relying on expiry alone.
  • Store the value inside tap on the miss path so caching is a pure side effect that never mutates the emitted response.
  • For multi-instance deployments, back the cache with Redis so a value cached on one node is visible to all.
Last updated June 14, 2026
Was this helpful?