Skip to content

Caching

Implementing Effective Caching with TypeORM

Section titled “Implementing Effective Caching with TypeORM”

Caching stores frequently accessed data in fast-access memory to reduce database load and improve response times.

Caching Layers
================================================================================
Request Flow:
+------------------+ +------------------+ +------------------+
| Client | --> | Application | --> | Database |
+------------------+ +------------------+ +------------------+
|
v
+------------------+
| Cache |
| (Redis/Memory) |
+------------------+
Cache Hit: Return from cache (fast)
Cache Miss: Query database, store in cache, return
Benefits:
- Reduced database load
- Faster response times
- Lower latency
- Better scalability
================================================================================

src/config/database.config.ts
import { DataSource } from 'typeorm';
export default new DataSource({
type: 'postgres',
// ... connection settings
// Enable query caching
cache: true, // Simple enable with defaults
// Or with configuration
cache: {
type: 'redis', // 'redis', 'ioredis', or 'database'
options: {
host: 'localhost',
port: 6379,
password: 'secret',
db: 0,
},
duration: 30000, // Default TTL: 30 seconds
ignoreErrors: true, // Don't throw on cache errors
},
});
// Cache query result
const users = await userRepository
.createQueryBuilder('user')
.cache(true) // Use default TTL
.getMany();
// Cache with custom duration (milliseconds)
const users = await userRepository
.createQueryBuilder('user')
.cache(60000) // 1 minute
.getMany();
// Cache with custom key
const users = await userRepository
.createQueryBuilder('user')
.cache('users_active_list', 60000)
.getMany();
// Cache with query identifier
const users = await userRepository
.createQueryBuilder('user')
.where('user.isActive = :active', { active: true })
.cache('active_users', 120000) // 2 minutes
.getMany();
import { Injectable, InjectDataSource } from '@nestjs/common';
import { DataSource } from 'typeorm';
@Injectable()
export class CacheService {
constructor(
@InjectDataSource()
private dataSource: DataSource,
) {}
// Clear all cache
async clearAll(): Promise<void> {
await this.dataSource.queryResultCache?.clear();
}
// Remove specific cache key
async remove(key: string): Promise<void> {
await this.dataSource.queryResultCache?.remove([key]);
}
// Remove multiple keys
async removeMany(keys: string[]): Promise<void> {
await this.dataSource.queryResultCache?.remove(keys);
}
}

src/app.module.ts
import { Module } from '@nestjs/common';
import { CacheModule, CacheStore } from '@nestjs/cache-manager';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as redisStore from 'cache-manager-redis-yet';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => {
const store = await redisStore({
socket: {
host: 'localhost',
port: 6379,
},
ttl: 30000, // Default TTL
});
return {
store: store as unknown as CacheStore,
};
},
}),
TypeOrmModule.forRoot({
// ... database config
}),
],
})
export class AppModule {}
src/users/users.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@Inject(CACHE_MANAGER)
private cacheManager: Cache,
) {}
async findOne(id: number): Promise<User> {
// Try cache first
const cacheKey = `user:${id}`;
const cached = await this.cacheManager.get<User>(cacheKey);
if (cached) {
return cached;
}
// Cache miss - query database
const user = await this.userRepository.findOne({
where: { id },
});
if (user) {
// Store in cache
await this.cacheManager.set(cacheKey, user, 60000);
}
return user;
}
async update(id: number, data: Partial<User>): Promise<User> {
const user = await this.userRepository.save({ id, ...data });
// Invalidate cache
await this.cacheManager.del(`user:${id}`);
return user;
}
async remove(id: number): Promise<void> {
await this.userRepository.delete(id);
// Invalidate cache
await this.cacheManager.del(`user:${id}`);
}
}

src/common/cache/cache-aside.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class CacheAsideService {
constructor(
@Inject(CACHE_MANAGER)
private cacheManager: Cache,
) {}
/**
* Get from cache or execute factory function
*/
async getOrSet<T>(
key: string,
factory: () => Promise<T>,
ttl: number = 30000,
): Promise<T> {
// Try cache
const cached = await this.cacheManager.get<T>(key);
if (cached !== undefined && cached !== null) {
return cached;
}
// Execute factory
const value = await factory();
// Store in cache
await this.cacheManager.set(key, value, ttl);
return value;
}
/**
* Get from cache or execute factory with null handling
*/
async getOrSetWithNull<T>(
key: string,
factory: () => Promise<T | null>,
ttl: number = 30000,
): Promise<T | null> {
const cached = await this.cacheManager.get<T | null>(key);
// Check if we have a cached value (including explicit null)
if (cached !== undefined) {
return cached;
}
const value = await factory();
// Cache even null values to prevent cache stampede
await this.cacheManager.set(key, value ?? null, ttl);
return value;
}
/**
* Invalidate multiple keys by pattern
*/
async invalidatePattern(pattern: string): Promise<void> {
// For Redis-based cache
const store = this.cacheManager.store as any;
if (store.keys) {
const keys = await store.keys(pattern);
await Promise.all(keys.map((key: string) => this.cacheManager.del(key)));
}
}
}
src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CacheAsideService } from '../common/cache/cache-aside.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private cacheAside: CacheAsideService,
) {}
async findOne(id: number): Promise<User | null> {
return this.cacheAside.getOrSetWithNull(
`user:${id}`,
() => this.userRepository.findOne({ where: { id } }),
60000, // 1 minute TTL
);
}
async findByEmail(email: string): Promise<User | null> {
return this.cacheAside.getOrSetWithNull(
`user:email:${email}`,
() => this.userRepository.findOne({ where: { email } }),
300000, // 5 minutes TTL
);
}
async update(id: number, data: Partial<User>): Promise<User> {
const user = await this.userRepository.save({ id, ...data });
// Invalidate related caches
await this.cacheAside.invalidatePattern(`user:*${id}*`);
await this.cacheAside.invalidatePattern(`user:email:*`);
return user;
}
}

Cache Invalidation Strategies
================================================================================
1. Write-Through
- Update cache immediately when data changes
- Pros: Cache always consistent
- Cons: Higher write latency
2. Write-Behind (Write-Back)
- Update cache, async update database
- Pros: Fast writes
- Cons: Risk of data loss
3. Write-Around
- Update database, invalidate cache
- Pros: No cache write overhead
- Cons: Cache miss on next read
4. Read-Through
- Cache loads data on miss
- Pros: Simplified application code
- Cons: Initial latency on miss
Recommended: Write-Around + Read-Through (Cache-Aside)
================================================================================
// Different TTL for different data types
const TTL = {
USER_PROFILE: 300000, // 5 minutes
USER_LIST: 60000, // 1 minute
CONFIGURATION: 3600000, // 1 hour
STATIC_DATA: 86400000, // 24 hours
SESSION: 1800000, // 30 minutes
};
// Usage
await this.cacheAside.getOrSet(
`user:${id}`,
() => this.userRepository.findOne({ where: { id } }),
TTL.USER_PROFILE,
);
src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { CacheAsideService } from '../common/cache/cache-aside.service';
@Injectable()
export class UsersService {
constructor(
private userRepository: Repository<User>,
private cacheAside: CacheAsideService,
private eventEmitter: EventEmitter2,
) {}
async update(id: number, data: Partial<User>): Promise<User> {
const user = await this.userRepository.save({ id, ...data });
// Emit event for cache invalidation
this.eventEmitter.emit('user.updated', { userId: id });
return user;
}
}
// src/users/listeners/cache.listener.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { CacheAsideService } from '../../common/cache/cache-aside.service';
@Injectable()
export class CacheListener {
constructor(private cacheAside: CacheAsideService) {}
@OnEvent('user.updated')
async handleUserUpdated(event: { userId: number }) {
await this.cacheAside.invalidatePattern(`user:*${event.userId}*`);
}
@OnEvent('user.deleted')
async handleUserDeleted(event: { userId: number }) {
await this.cacheAside.invalidatePattern(`user:*${event.userId}*`);
}
}

src/common/cache/stampede-proof.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class StampedeProofCacheService {
private pendingRequests = new Map<string, Promise<any>>();
constructor(
@Inject(CACHE_MANAGER)
private cacheManager: Cache,
) {}
async getOrSet<T>(
key: string,
factory: () => Promise<T>,
ttl: number = 30000,
): Promise<T> {
// Check cache
const cached = await this.cacheManager.get<T>(key);
if (cached !== undefined) {
return cached;
}
// Check if request is already pending
const pending = this.pendingRequests.get(key);
if (pending) {
return pending;
}
// Create new request
const request = this.executeFactory(key, factory, ttl);
this.pendingRequests.set(key, request);
try {
return await request;
} finally {
this.pendingRequests.delete(key);
}
}
private async executeFactory<T>(
key: string,
factory: () => Promise<T>,
ttl: number,
): Promise<T> {
const value = await factory();
await this.cacheManager.set(key, value, ttl);
return value;
}
}
src/common/cache/multi-level.service.ts
import { Injectable } from '@nestjs/common';
interface CacheLayer {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T, ttl: number): Promise<void>;
del(key: string): Promise<void>;
}
@Injectable()
export class MultiLevelCacheService {
private layers: CacheLayer[] = [];
addLayer(layer: CacheLayer): void {
this.layers.push(layer);
}
async get<T>(key: string): Promise<T | undefined> {
for (const layer of this.layers) {
const value = await layer.get<T>(key);
if (value !== undefined) {
// Populate upper layers
await this.populateUpperLayers(key, value, layer);
return value;
}
}
return undefined;
}
async set<T>(key: string, value: T, ttl: number): Promise<void> {
await Promise.all(
this.layers.map(layer => layer.set(key, value, ttl))
);
}
async del(key: string): Promise<void> {
await Promise.all(
this.layers.map(layer => layer.del(key))
);
}
private async populateUpperLayers<T>(
key: string,
value: T,
foundLayer: CacheLayer,
): Promise<void> {
const index = this.layers.indexOf(foundLayer);
const upperLayers = this.layers.slice(0, index);
await Promise.all(
upperLayers.map(layer => layer.set(key, value, 30000))
);
}
}

src/common/decorators/cacheable.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const CACHE_KEY = 'cache:key';
export const CACHE_TTL = 'cache:ttl';
export const Cacheable = (key: string, ttl: number = 30000) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
SetMetadata(CACHE_KEY, key)(target, propertyKey, descriptor);
SetMetadata(CACHE_TTL, ttl)(target, propertyKey, descriptor);
};
};
// src/common/interceptors/cache.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Inject,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Observable, from, of } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
import { CACHE_KEY, CACHE_TTL } from '../decorators/cacheable.decorator';
@Injectable()
export class CacheInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
@Inject(CACHE_MANAGER)
private cacheManager: Cache,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const cacheKey = this.reflector.get<string>(CACHE_KEY, context.getHandler());
const cacheTtl = this.reflector.get<number>(CACHE_TTL, context.getHandler());
if (!cacheKey) {
return next.handle();
}
return from(this.cacheManager.get(cacheKey)).pipe(
switchMap(cached => {
if (cached !== undefined) {
return of(cached);
}
return next.handle().pipe(
tap(data => {
this.cacheManager.set(cacheKey, data, cacheTtl || 30000);
}),
);
}),
);
}
}
// Usage
@Injectable()
export class UsersService {
@Cacheable('users:all', 60000)
async findAll(): Promise<User[]> {
return this.userRepository.find();
}
@Cacheable('user:${id}', 300000) // Note: key interpolation not supported
async findOne(id: number): Promise<User> {
return this.userRepository.findOne({ where: { id } });
}
}

src/common/cache/cache-stats.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
interface CacheStats {
hits: number;
misses: number;
hitRate: number;
}
@Injectable()
export class CacheStatsService {
private stats = new Map<string, CacheStats>();
constructor(
@Inject(CACHE_MANAGER)
private cacheManager: Cache,
) {}
async getWithStats<T>(
key: string,
factory: () => Promise<T>,
ttl: number = 30000,
): Promise<T> {
const cached = await this.cacheManager.get<T>(key);
if (cached !== undefined) {
this.recordHit(key);
return cached;
}
this.recordMiss(key);
const value = await factory();
await this.cacheManager.set(key, value, ttl);
return value;
}
private recordHit(key: string): void {
const stats = this.getOrCreateStats(key);
stats.hits++;
stats.hitRate = stats.hits / (stats.hits + stats.misses);
}
private recordMiss(key: string): void {
const stats = this.getOrCreateStats(key);
stats.misses++;
stats.hitRate = stats.hits / (stats.hits + stats.misses);
}
private getOrCreateStats(key: string): CacheStats {
if (!this.stats.has(key)) {
this.stats.set(key, { hits: 0, misses: 0, hitRate: 0 });
}
return this.stats.get(key)!;
}
getStats(): Map<string, CacheStats> {
return this.stats;
}
getOverallStats(): CacheStats {
let totalHits = 0;
let totalMisses = 0;
this.stats.forEach(stat => {
totalHits += stat.hits;
totalMisses += stat.misses;
});
return {
hits: totalHits,
misses: totalMisses,
hitRate: totalHits / (totalHits + totalMisses) || 0,
};
}
}

Caching Quick Reference
+------------------------------------------------------------------+
| |
| Strategy | Use Case |
| -------------------|------------------------------------------|
| Cache-Aside | General purpose, most flexible |
| Write-Through | Strong consistency needed |
| Write-Around | Reduce cache writes |
| Read-Through | Simplified application code |
| |
| TTL Guidelines | Data Type |
| -------------------|------------------------------------------|
| 30 seconds | Frequently changing data |
| 1-5 minutes | User profiles, lists |
| 1 hour | Configuration, settings |
| 24 hours | Static data, reference tables |
| |
| Best Practices | Description |
| -------------------|------------------------------------------|
| Cache keys | Use consistent naming convention |
| Invalidation | Invalidate on data change |
| Stampede | Prevent concurrent cache misses |
| Monitoring | Track hit rates and performance |
| |
+------------------------------------------------------------------+

Chapter 34: Connection Pooling


Last Updated: February 2026