import { SetMetadata } from '@nestjs/common';
export const CACHEABLE_KEY = 'cacheable:options';
export type VaryDimension = 'user' | 'tenant' | 'query';
export interface CacheableOptions {
ttl: number; // seconds
varyBy?: VaryDimension[];
}
export const Cacheable = (options: CacheableOptions) =>
SetMetadata(CACHEABLE_KEY, {
ttl: options.ttl,
varyBy: options.varyBy ?? [],
});
import { createHash } from 'crypto';
import { Request } from 'express';
import { VaryDimension } from './cacheable.decorator';
export class CacheKeyBuilder {
static build(req: Request, varyBy: VaryDimension[]): string {
const parts: string[] = [req.method, this.normalizePath(req)];
if (varyBy.includes('query')) {
parts.push(this.normalizeQuery(req.query));
}
if (varyBy.includes('user')) {
const user = (req as any).user;
parts.push(`u:${user?.id ?? 'anon'}`);
}
if (varyBy.includes('tenant')) {
parts.push(`t:${req.header('x-tenant-id') ?? 'default'}`);
}
const composite = parts.join('|');
return `route:${createHash('sha1').update(composite).digest('hex')}`;
}
private static normalizePath(req: Request): string {
return req.baseUrl + req.path;
}
private static normalizeQuery(query: Record<string, unknown>): string {
return Object.keys(query)
.sort()
.map((k) => `${k}=${String(query[k])}`)
.join('&');
}
}
import {
CallHandler,
ExecutionContext,
Inject,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Reflector } from '@nestjs/core';
import { Cache } from 'cache-manager';
import { Request } from 'express';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CACHEABLE_KEY, CacheableOptions } from './cacheable.decorator';
import { CacheKeyBuilder } from './cache-key.builder';
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(
private readonly reflector: Reflector,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<unknown>> {
const options = this.reflector.get<CacheableOptions>(
CACHEABLE_KEY,
context.getHandler(),
);
const req = context.switchToHttp().getRequest<Request>();
if (!options || req.method !== 'GET') {
return next.handle();
}
const key = CacheKeyBuilder.build(req, options.varyBy ?? []);
const cached = await this.cache.get(key);
if (cached !== undefined && cached !== null) {
return of(cached);
}
return next.handle().pipe(
tap((body) => {
void this.cache.set(key, body, options.ttl * 1000);
}),
);
}
}
import { Controller, Get, Query, UseInterceptors } from '@nestjs/common';
import { HttpCacheInterceptor } from './cache.interceptor';
import { Cacheable } from './cacheable.decorator';
import { ReportsService } from './reports.service';
@Controller('reports')
@UseInterceptors(HttpCacheInterceptor)
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get('revenue')
@Cacheable({ ttl: 120, varyBy: ['tenant', 'query'] })
getRevenue(@Query('period') period = 'month') {
return this.reports.computeRevenue(period);
}
@Get('me/summary')
@Cacheable({ ttl: 30, varyBy: ['user'] })
getMySummary() {
return this.reports.buildUserSummary();
}
}
This snippet shows how to build per-route response caching in NestJS without relying entirely on the built-in CacheModule defaults, giving full control over which routes are cached, for how long, and how the cache key is derived from the request. The pattern is useful when a handful of read-heavy endpoints run expensive queries whose results are safe to serve slightly stale, and where the default global cache key (just the URL) is too coarse because the response also depends on the authenticated user, query parameters, or a tenant header.
In cacheable.decorator.ts, a @Cacheable decorator attaches metadata to the handler using SetMetadata. It carries a TTL and an optional varyBy list of request attributes, so caching becomes an explicit, opt-in choice on each route rather than an all-or-nothing global setting. Only handlers annotated with this decorator are ever cached; everything else passes straight through.
cache-key.builder.ts centralizes key construction. CacheKeyBuilder.build combines the HTTP method, path, and a normalized query string, then folds in any varyBy dimensions such as the user id or a tenant header. Query params are sorted so ?a=1&b=2 and ?b=2&a=1 collapse to the same key, avoiding accidental cache misses. The long composite string is hashed with createHash('sha1') to keep Redis keys compact and free of unsafe characters.
cache.interceptor.ts ties it together. HttpCacheInterceptor reads the metadata with Reflector; when absent it returns next.handle() untouched. It also skips non-GET requests, since caching mutations would be dangerous. On a hit it short-circuits with of(cached), never invoking the handler. On a miss it lets the handler run and uses the RxJS tap operator to write the result back with the configured TTL. Because tap runs on the success path only, error responses are never cached.
The main trade-off is staleness: callers may receive data up to ttl seconds old, so this fits reference or aggregate data, not fast-changing balances. A subtle pitfall is caching per-user data under a shared key, which varyBy prevents. Reflect.getMetadata combined with SetMetadata is the idiomatic NestJS way to make cross-cutting behavior configurable per handler.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
Share this code
Here's the card — post it anywhere.