Caching in Node.js, NestJS, Redis, and HTTP
Learn where caching removes repeated work, how cache-aside behaves, and how stale data, stampedes, and unbounded memory cause incidents.
Timeline
Understanding: Caching in Node.js, NestJS, Redis, and HTTP
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
A cache is like a small shelf next to your desk. Keeping a frequently used book there is cheaper than walking to the archive every time. The shelf is limited, its copy can become outdated, and an archive update must tell you which shelf copy to remove.
A cache stores a derived copy of data or computation closer to its consumer. A hit avoids the slower primary source, while a miss adds cache lookup before ordinary loading. A correct design defines key identity, lifetime, size bounds, invalidation, concurrency behavior, source of truth, and acceptable staleness.
Terms used in this experiment
Understand the words first, then the execution order.
Cache hit
The key exists and the result is returned without contacting the primary source.
Cache miss
The key is absent or expired, so the application loads from primary and usually stores a copy.
Hit ratio
The share served from cache: hits divided by hits plus misses; it matters only with latency and correctness.
TTL
Time To Live, after which an entry expires and the next access becomes a miss.
Eviction
Removal under a TTL, LRU, LFU, or other policy so bounded cache memory can admit new entries.
Invalidation
Explicit removal or replacement of a cached copy after its source of truth changes.
Cache-aside
The app reads cache first, loads primary on a miss, stores a copy, and deletes the key after a write.
Cache stampede
Many callers miss one hot key simultaneously and repeat the same expensive loader.
Single-flight
Concurrent misses for one key share an in-flight Promise or coordinated lock.
Stale data
A cached value no longer matches the source of truth but is still visible to a consumer.
Cache key
Stable result identity containing every relevant dimension such as entity, tenant, locale, filters, and version.
CDN
A Content Delivery Network: distributed shared HTTP caches close to users.
What happens step by step
Each step maps to an observable runtime state.
- 01Find repeated expensive work
Measure database queries, external calls, or CPU computation; do not cache a cheap unique lookup.
- 02Name the source of truth
PostgreSQL, an upstream API, or an immutable artifact remains authoritative and can rebuild the cache.
- 03Design the key
Include every response dimension; omitting tenant or permission scope can mix user data.
- 04Handle hit and miss
A hit returns immediately; a miss starts the loader and stores only a successful result.
- 05Bound lifetime and size
TTL bounds staleness while maximum entries or bytes and eviction bound memory independently.
- 06Define write policy
Cache-aside normally commits primary and then deletes the key; write-through and write-behind have different costs.
- 07Protect a hot miss
Single-flight, locks, stale-while-revalidate, or TTL jitter prevent synchronized primary load.
- 08Choose a cache level
Local memory is fastest, Redis is shared by replicas, and HTTP or CDN caching can bypass Node entirely.
- 09Measure the result
Observe hits, misses, load duration, evictions, memory, Redis errors, stale age, and primary load.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
No cache is not always a defect
A rare high-cardinality query gets few hits while lookup, serialization, and invalidation add overhead.
Local cache is not shared
Every Node process, Worker, or Kubernetes Pod owns memory and can temporarily expose a different revision.
TTL does not bound key count
Millions of unique entries can be created during one TTL window, so maximum size and eviction remain necessary.
Zero and false are valid values
Check miss with undefined or null according to the API rather than if (!value).
Negative caching can help
Short-lived “not found” entries protect the database but can hide a newly created object until invalidated.
TTL jitter distributes expiry
A small random offset prevents thousands of keys from expiring in the same second.
Cache errors can often degrade
A derived cache timeout can fall back to primary, but fallback must be bounded so cache failure does not overload it.
Authorization needs stricter freshness
A long permission TTL can preserve revoked access, requiring short lifetime, versioned keys, or reliable invalidation.
HTTP caching stores representations
Cache-Control controls freshness, ETag validates versions, and Vary separates response variants.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
@Injectable()
export class ProductsService {
constructor(
@Inject(CACHE_MANAGER)
private readonly cache: Cache,
private readonly products: ProductsRepository,
) {}
async findOne(id: string) {
const key = `product:v1:${id}`;
const hit = await this.cache.get<Product>(key);
if (hit !== undefined && hit !== null) return hit;
const product = await this.products.findById(id);
await this.cache.set(key, product, 30_000);
return product;
}
}The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
import 'reflect-metadata';
import {
Dependencies,
Injectable,
Module,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import {
CACHE_MANAGER,
CacheModule,
} from '@nestjs/cache-manager';
import { performance } from 'node:perf_hooks';
const CACHE_TRACE = Symbol('CACHE_TRACE');
const PRODUCT_TTL_MS = 120;
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
class ProductRepository {
constructor() {
this.readCount = 0;
this.products = new Map([
[
'book-42',
{
id: 'book-42',
name: 'Node.js Runtime',
price: 4200,
revision: 1,
},
],
]);
}
async findById(id) {
this.readCount += 1;
await wait(35);
const product = this.products.get(id);
return product ? structuredClone(product) : null;
}
async updatePrice(id, price) {
const current = this.products.get(id);
const updated = {
...current,
price,
revision: current.revision + 1,
};
this.products.set(id, updated);
return structuredClone(updated);
}
}
Injectable()(ProductRepository);
class ProductCacheService {
constructor(cache, products, trace) {
this.cache = cache;
this.products = products;
this.trace = trace;
this.inFlight = new Map();
}
key(id) {
return `product:v1:${id}`;
}
async getById(id) {
const key = this.key(id);
const cached = await this.cache.get(key);
if (cached !== undefined && cached !== null) {
this.trace.emit('cache', 'hit', `HIT ${key}: repository не вызывается`);
return cached;
}
if (this.inFlight.has(key)) {
this.trace.emit(
'single-flight',
'join',
`MISS ${key}: запрос присоединён к уже выполняющемуся loader`,
);
return this.inFlight.get(key);
}
this.trace.emit(
'cache',
'miss',
`MISS ${key}: выполняется repository.findById`,
);
const loading = this.products
.findById(id)
.then(async (product) => {
await this.cache.set(key, product, PRODUCT_TTL_MS);
return product;
})
.finally(() => {
this.inFlight.delete(key);
});
this.inFlight.set(key, loading);
return loading;
}
async updatePrice(id, price) {
const updated = await this.products.updatePrice(id, price);
await this.cache.del(this.key(id));
this.trace.emit(
'invalidation',
'delete',
`UPDATE записан в primary; ключ ${this.key(id)} удалён`,
);
return updated;
}
}
Dependencies(CACHE_MANAGER, ProductRepository, CACHE_TRACE)(
ProductCacheService,
);
Injectable()(ProductCacheService);
class CacheLabModule {}
function configureCacheLab(trace) {
Module({
imports: [
CacheModule.register({
ttl: PRODUCT_TTL_MS,
}),
],
providers: [
ProductRepository,
ProductCacheService,
{
provide: CACHE_TRACE,
useValue: trace,
},
],
})(CacheLabModule);
return CacheLabModule;
}
export async function cachingStrategies(emit) {
const trace = { emit };
const application = await NestFactory.createApplicationContext(
configureCacheLab(trace),
{ logger: false },
);
try {
const repository = application.get(ProductRepository);
const service = application.get(ProductCacheService);
emit(
'baseline',
'start',
'Без cache три одинаковых чтения трижды занимают repository и connection',
);
const baselineStarted = performance.now();
await repository.findById('book-42');
await repository.findById('book-42');
await repository.findById('book-42');
const baselineMs = Math.round(performance.now() - baselineStarted);
emit(
'baseline',
'result',
`Без cache: reads=3, elapsed≈${baselineMs} мс`,
);
const readsBeforeCache = repository.readCount;
const cachedStarted = performance.now();
await service.getById('book-42');
await service.getById('book-42');
await service.getById('book-42');
const cachedMs = Math.round(performance.now() - cachedStarted);
emit(
'cache',
'result',
`Cache-aside: repository reads=${repository.readCount - readsBeforeCache}, elapsed≈${cachedMs} мс`,
);
await wait(PRODUCT_TTL_MS + 20);
const readsBeforeExpiry = repository.readCount;
await service.getById('book-42');
emit(
'ttl',
'expired',
`После TTL новый MISS добавил repository reads=${repository.readCount - readsBeforeExpiry}`,
);
const cache = application.get(CACHE_MANAGER);
await cache.del(service.key('book-42'));
const readsBeforeBurst = repository.readCount;
await Promise.all(
Array.from({ length: 5 }, () => service.getById('book-42')),
);
emit(
'single-flight',
'result',
`Пять одновременных MISS вызвали loader ${repository.readCount - readsBeforeBurst} раз вместо 5`,
);
await service.updatePrice('book-42', 4500);
const fresh = await service.getById('book-42');
emit(
'invalidation',
'result',
`После invalidation прочитана revision=${fresh.revision}, price=${fresh.price}`,
);
emit(
'architecture',
'boundary',
'In-memory cache принадлежит одному Node process; Redis нужен для общего cache нескольких replicas',
);
} finally {
await application.close();
emit('cleanup', 'done', 'Nest application context и cache store закрыты');
}
}
The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.
Practical patterns worth keeping nearby
Compare the goal, code, and caveats instead of memorizing syntax without a model.
Node.js: minimal cache-aside
Avoid running one expensive loader on every sequential read.
const cache = new Map();
async function getProduct(id) {
const key = `product:${id}`;
const entry = cache.get(key);
if (entry && entry.expiresAt > Date.now()) {
return entry.value;
}
const product = await repository.findById(id);
cache.set(key, {
value: product,
expiresAt: Date.now() + 30_000,
});
return product;
}- Without cache, every read holds a database connection and pays full latency.
- This teaching Map still needs maximum-size eviction.
- Each process has a separate copy in a multi-process deployment.
Nest: CacheModule and CACHE_MANAGER
Obtain a managed cache store through dependency injection.
@Module({
imports: [CacheModule.register({ ttl: 30_000 })],
providers: [ProductsService],
})
export class ProductsModule {}
@Injectable()
export class ProductsService {
constructor(
@Inject(CACHE_MANAGER)
private readonly cache: Cache,
) {}
get(id: string) {
return this.cache.get(`product:v1:${id}`);
}
}- CacheModule owns provider lifecycle.
- Current cache-manager TTL is expressed in milliseconds.
- v1 allows a future cached-value schema change.
Nest: cache-aside with invalidation
Avoid returning an old price after a successful update.
async findOne(id: string) {
const key = `product:v1:${id}`;
const hit = await this.cache.get<Product>(key);
if (hit !== undefined && hit !== null) return hit;
const product = await this.products.findById(id);
await this.cache.set(key, product, 30_000);
return product;
}
async update(id: string, dto: UpdateProductDto) {
const product = await this.products.update(id, dto);
await this.cache.del(`product:v1:${id}`);
return product;
}- Primary update happens before eviction.
- The next read repopulates the new revision.
- Define behavior for cache.del failure after a successful database commit.
Nest CacheInterceptor for a simple GET
Cache a safe representation without manual controller get and set calls.
@Controller('public/catalog')
@UseInterceptors(CacheInterceptor)
export class CatalogController {
@Get()
@CacheTTL(15_000)
findAll() {
return this.catalog.findPublicItems();
}
}- The official interceptor automatically caches GET responses.
- Personalized responses need correct key tracking.
- Never response-cache business commands and side effects.
Single-flight against a stampede
Let five concurrent misses share one database Promise.
const inFlight = new Map<string, Promise<Product>>();
async function loadOnce(key: string) {
const running = inFlight.get(key);
if (running) return running;
const promise = repository
.findById(key)
.finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}- finally removes coordination state, not the cached value.
- Local single-flight protects only one process.
- Multiple replicas need a Redis lock, early refresh, or other distributed mechanism.
Redis cache-aside
Share a hot cache across multiple Node or Nest replicas.
async function getProduct(id) {
const key = `product:v1:${id}`;
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached);
const product = await repository.findById(id);
await redis.set(key, JSON.stringify(product), {
EX: 30 + Math.floor(Math.random() * 6),
});
return product;
}- Redis adds a network hop but provides a shared copy.
- EX is in seconds; verify the API of the chosen client.
- Random jitter spreads expiration.
External API: short cache
Avoid exhausting a provider rate limit with identical calls.
const key = `shipping:v2:${country}:${postalCode}:${weight}`;
const cached = await cache.get(key);
if (cached) return cached;
const quote = await shippingProvider.quote(input);
await cache.set(key, quote, 60_000);
return quote;- Without cache, a traffic spike repeats network latency and consumes quota.
- The key must contain every calculation input.
- TTL follows the business freshness requirement.
HTTP browser and CDN cache
Avoid sending an unchanged public response through Node for every user.
response.setHeader(
'Cache-Control',
'public, max-age=60, s-maxage=300, stale-while-revalidate=30',
);
response.setHeader('ETag', versionHash);- max-age controls browser freshness.
- s-maxage controls shared cache or CDN freshness.
- ETag enables 304 without retransmitting the body.
- Personalized data usually needs private or no-store.
What to observe
Prove that caching helps and does not conceal an incident.
cache_requests_total{result="hit"}
cache_requests_total{result="miss"}
cache_load_duration_seconds
cache_evictions_total
cache_entries
cache_stale_served_total
primary_queries_total- Read hit ratio alongside primary load and end-to-end latency.
- Entries and memory expose missing bounds.
- A miss storm appears as a synchronized loader spike.
How a learning mistake becomes an incident
A realistic service: the original code, observable failure, corrected implementation, and why the correction works.
The home page repeats one expensive query for every visitor
A popular Nest catalog endpoint runs the same aggregate JOIN. Data changes a few times per minute, but every HTTP request occupies another PostgreSQL connection.
Without caching, database queries grow almost linearly with traffic. Pool wait and p95 rise first; timeouts then trigger retries that add even more primary load.
@Injectable()
export class FeaturedProductsQuery {
constructor(private readonly db: Database) {}
async execute(locale: string) {
return this.db.query(
`SELECT p.id, t.name, avg(r.score) AS rating
FROM products p
JOIN translations t ON t.product_id = p.id
LEFT JOIN reviews r ON r.product_id = p.id
WHERE p.featured AND t.locale = $1
GROUP BY p.id, t.name
ORDER BY rating DESC NULLS LAST
LIMIT 24`,
[locale],
);
}
}The query is correct, but thousands of consumers repeat identical work. Even a fast plan consumes connection time, CPU, and buffers; a traffic spike can exhaust a small pool.
@Injectable()
export class FeaturedProductsQuery {
private readonly inFlight = new Map<string, Promise<Product[]>>();
constructor(
@Inject(CACHE_MANAGER)
private readonly cache: Cache,
private readonly repository: ProductsRepository,
) {}
async execute(locale: string) {
const key = `featured:v2:${locale}`;
const hit = await this.cache.get<Product[]>(key);
if (hit !== undefined && hit !== null) return hit;
const running = this.inFlight.get(key);
if (running) return running;
const loading = this.repository
.findFeatured(locale)
.then(async (rows) => {
await this.cache.set(key, rows, 10_000);
return rows;
})
.finally(() => this.inFlight.delete(key));
this.inFlight.set(key, loading);
return loading;
}
invalidate(locale: string) {
return this.cache.del(`featured:v2:${locale}`);
}
}The key separates locale and schema version. TTL bounds staleness, invalidation runs after featured data changes, and an in-flight Promise prevents concurrent local misses from duplicating SQL.
What the unfamiliar calls from both code samples actually do.
@Inject(CACHE_MANAGER)- Asks the Nest dependency-injection container for the cache-manager instance created by CacheModule.
cache.get(key)- Reads the derived copy under one exact key; undefined or null is a miss while zero, false, and an empty string can be valid hits.
cache.set(key, value, ttl)- Stores a serializable copy for a bounded lifetime; current Nest cache-manager uses a millisecond TTL.
cache.del(key)- Invalidates one cached copy after primary state changes so the next read loads the new revision.
inFlight.get(key)- Checks whether the current process already runs this key loader and lets a concurrent caller reuse its Promise.
finally(() => inFlight.delete(key))- Removes coordination after success or failure so a rejected loader cannot block future attempts forever.
stableHash(dimensions)- A representative helper that canonically serializes result dimensions into a compact key without raw personal data.
ttlJitter(maxMs)- A representative helper that adds a small random TTL offset so popular keys do not expire simultaneously.
singleFlight.do(key, loader)- A conceptual abstraction that joins parallel calls for one key; local implementations store a Promise while distributed ones coordinate externally.
featured:v2:${locale}- A versioned key includes locale because each language produces a different catalog representation.
Calculating shipping on every edit exhausts an external API quota
The checkout frontend refines a cart and repeats shipping-price requests. A Nest service calls the paid provider every time even when country, postal code, weight, and cart revision are unchanged.
Without caching, every request pays network latency and total traffic quickly reaches the provider rate limit. Retries after 429 can amplify load synchronously.
@Injectable()
export class ShippingService {
constructor(private readonly provider: ShippingProvider) {}
quote(input: ShippingQuoteDto) {
return this.provider.quote({
country: input.country,
postalCode: input.postalCode,
weight: input.weight,
items: input.items,
});
}
}A result that is deterministic for a short window is never reused. The endpoint inherits all latency, quota pressure, and brief outages of the upstream provider.
@Injectable()
export class ShippingService {
constructor(
@Inject(CACHE_MANAGER)
private readonly cache: Cache,
private readonly provider: ShippingProvider,
private readonly singleFlight: SingleFlight,
) {}
async quote(input: ShippingQuoteDto) {
const dimensions = {
country: input.country,
postalCode: input.postalCode.trim().toUpperCase(),
weight: input.weight,
cartRevision: input.cartRevision,
};
const key = `shipping:v3:${stableHash(dimensions)}`;
const hit = await this.cache.get<ShippingQuote>(key);
if (hit !== undefined && hit !== null) return hit;
return this.singleFlight.do(key, async () => {
const quote = await this.provider.quote(input);
await this.cache.set(key, quote, 60_000 + ttlJitter(5_000));
return quote;
});
}
}The key includes every result dimension without raw personal data. Short TTL matches quote freshness, jitter spreads expiry, and single-flight reduces parallel upstream calls.
What the unfamiliar calls from both code samples actually do.
@Inject(CACHE_MANAGER)- Asks the Nest dependency-injection container for the cache-manager instance created by CacheModule.
cache.get(key)- Reads the derived copy under one exact key; undefined or null is a miss while zero, false, and an empty string can be valid hits.
cache.set(key, value, ttl)- Stores a serializable copy for a bounded lifetime; current Nest cache-manager uses a millisecond TTL.
cache.del(key)- Invalidates one cached copy after primary state changes so the next read loads the new revision.
inFlight.get(key)- Checks whether the current process already runs this key loader and lets a concurrent caller reuse its Promise.
finally(() => inFlight.delete(key))- Removes coordination after success or failure so a rejected loader cannot block future attempts forever.
stableHash(dimensions)- A representative helper that canonically serializes result dimensions into a compact key without raw personal data.
ttlJitter(maxMs)- A representative helper that adds a small random TTL offset so popular keys do not expire simultaneously.
singleFlight.do(key, loader)- A conceptual abstraction that joins parallel calls for one key; local implementations store a Promise while distributed ones coordinate externally.
featured:v2:${locale}- A versioned key includes locale because each language produces a different catalog representation.
Common misconceptions
The myth is on the left; the accurate model is on the right.
A cache always makes an application faster.
At low hit ratio, lookup, serialization, network, and invalidation can make the path slower.
A global Map is enough.
Without TTL, maximum size, and eviction, it becomes a memory leak and disappears on restart.
TTL completely solves consistency.
TTL only bounds a stale window; critical writes usually need invalidation or versioning.
Redis is the source of truth.
An ordinary cache must be rebuildable while durable business state belongs in an appropriate primary store.
One miss produces one database query.
Hundreds of concurrent callers can all miss; use single-flight or distributed coordination.
Caching POST prevents double payment.
Business side effects need idempotency keys and database constraints rather than a response cache.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- When does missing cache cause a problem, and when does cache only add complexity?
- How do TTL, maximum size, and eviction differ?
- Why does a local Map not share values between two Kubernetes Pods?
- Which fields belong in the key of a personalized endpoint?
- Why is if (!cached) wrong when zero is a valid cached value?
- How does single-flight protect the primary from a stampede?
- In which order does cache-aside update primary and delete the key?
- How does CacheInterceptor differ from manual CACHE_MANAGER use?
- When is HTTP or CDN caching more useful than Redis?
- Which metrics prove that caching actually helps?