Caching is one of the simplest ways to make a Node.js application faster without immediately scaling the underlying infrastructure. Instead of repeatedly performing expensive database queries, API requests, or computations, a cache allows frequently requested data to be served from a faster storage layer.
The challenge is choosing the right caching strategy. Cache-aside, write-through, TTL-based expiration, and in-memory caching each solve different problems, and using the wrong approach can create stale data or unnecessary memory usage.
In this article, we will build a practical Node.js caching example using an in-memory cache. The example demonstrates cache hits, cache misses, TTL expiration, invalidation, and how caching can reduce repeated expensive operations.
Understanding Node.js Caching Strategies
A common approach in Node.js applications is cache-aside caching. The application first checks the cache for requested data and returns it immediately when available. If the data is missing, the application loads it from the database or another expensive source, stores the result in the cache, and then returns it to the caller.
TTL, or time-to-live, is important because cached data should not remain available forever. A short TTL works well for frequently changing data, while a longer TTL can be useful for relatively stable configuration or catalog information.
Cache invalidation is another important part of the design. When application data changes, explicitly removing the corresponding cache entry prevents users from receiving outdated information. For larger production systems, the same concepts can be implemented with Redis or another distributed cache so multiple Node.js instances can share cached data.
class MemoryCache {
constructor() {
this.store = new Map();
}
set(key, value, ttlMs = 10000) {
const expiresAt = Date.now() + ttlMs;
this.store.set(key, {
value,
expiresAt
});
console.log(`[CACHE] Stored key: ${key} | TTL: ${ttlMs}ms`);
}
get(key) {
const entry = this.store.get(key);
if (!entry) {
console.log(`[CACHE] MISS: ${key}`);
return null;
}
if (Date.now() > entry.expiresAt) {
console.log(`[CACHE] EXPIRED: ${key}`);
this.store.delete(key);
return null;
}
console.log(`[CACHE] HIT: ${key}`);
return entry.value;
}
delete(key) {
const deleted = this.store.delete(key);
console.log(`[CACHE] INVALIDATE: ${key} | Removed: ${deleted}`);
}
clear() {
this.store.clear();
console.log('[CACHE] All cached entries cleared');
}
size() {
return this.store.size;
}
}
const cache = new MemoryCache();
// Simulate an expensive database operation.
async function fetchUserFromDatabase(userId) {
console.log(`[DATABASE] Fetching user ${userId}...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
id: userId,
name: 'Ansh',
role: 'Senior JavaScript Developer',
updatedAt: new Date().toISOString()
};
}
// Cache-aside strategy: read from cache first, then database on a miss.
async function getUser(userId) {
const cacheKey = `user:${userId}`;
const cachedUser = cache.get(cacheKey);
if (cachedUser) {
console.log('[SERVICE] Returning cached user');
return cachedUser;
}
console.log('[SERVICE] Cache miss, loading from database');
const user = await fetchUserFromDatabase(userId);
// Cache the database result for five seconds.
cache.set(cacheKey, user, 5000);
console.log('[SERVICE] Returning fresh database result');
return user;
}
async function updateUser(userId, updates) {
console.log(`[DATABASE] Updating user ${userId}`);
await new Promise((resolve) => setTimeout(resolve, 500));
console.log(`[DATABASE] User updated: ${JSON.stringify(updates)}`);
// Invalidate cached data after a successful update.
cache.delete(`user:${userId}`);
}
async function main() {
const userId = 101;
console.log('\n--- REQUEST 1 ---');
const firstRequest = await getUser(userId);
console.log('[RESULT]', firstRequest);
console.log('\n--- REQUEST 2 ---');
const secondRequest = await getUser(userId);
console.log('[RESULT]', secondRequest);
console.log('\n--- CACHE STATUS ---');
console.log(`[CACHE] Entries: ${cache.size()}`);
console.log('\n--- UPDATE USER ---');
await updateUser(userId, { role: 'Lead JavaScript Developer' });
console.log('\n--- REQUEST 3 AFTER INVALIDATION ---');
const thirdRequest = await getUser(userId);
console.log('[RESULT]', thirdRequest);
console.log('\n--- WAITING FOR TTL EXPIRATION ---');
await new Promise((resolve) => setTimeout(resolve, 5500));
console.log('\n--- REQUEST 4 AFTER TTL ---');
const fourthRequest = await getUser(userId);
console.log('[RESULT]', fourthRequest);
console.log('\n--- FINAL CACHE STATUS ---');
console.log(`[CACHE] Entries: ${cache.size()}`);
}
main().catch((error) => {
console.error('[ERROR]', error);
process.exitCode = 1;
});
Conclusion
Caching should be treated as part of the application architecture rather than simply adding a faster data store. A well-designed cache can significantly reduce database load, improve response times, and make high-traffic Node.js services more resilient.
For small applications, an in-memory cache can be enough to understand and implement the fundamentals. For horizontally scaled production systems, Redis is often a better choice because multiple Node.js processes can share the same cache and use features such as TTLs, atomic operations, and distributed invalidation.
The most important lesson is to define what can be cached, how long it should live, and when it must be invalidated. Once those rules are clear, choosing between cache-aside, write-through, or another strategy becomes much easier.
Top comments (1)
Bài viết cover khá đầy đủ các layer caching, nhưng thực tế production có vài điểm "đau" hay bị bỏ qua:
Cache stampede khi key hot expired - solution đơn giản là
stale-while-revalidate(set TTL ngắn + background refresh) hoặc dùngmutex/singleflightpattern. Redis Lua script hoặcSET NXvới TTL ngắn là cách implement nhẹ nhất.Cache invalidation strategy - bài nhắc
cache-asidenhưng ít nói về khi nào invalidate. Pattern thực tế: event-driven (Kafka/RabbitMQ consumer listen DB changes → publish invalidation event) hoặc write-through với versioned keys (user:123:v5). TránhKEYS *hoặcSCANproduction.Memory pressure trên Node process - in-memory cache (LRU Map) dễ gây OOM nếu không limit size. Dùng
quick-lruhoặcmnemonistvớimaxSize+maxAge, monitorprocess.memoryUsage().heapUsed.Serialization overhead - JSON.parse/stringify chậm ở throughput cao. MessagePack hoặc Protocol Buffers nhanh hơn 2-3x, đặc biệt quan trọng khi cache object lớn (product catalog, user profile).
Observability - thiếu metrics: hit/miss ratio, latency p50/p99, eviction rate, memory fragmentation. Prometheus + Grafana dashboard tối thiểu 4 panel này.
Một gotcha hay gặp: cache warming sau deploy. Nếu không pre-warm, traffic spike đầu tiên sẽ kill DB. Script warm-up chạy trong CI/CD pipeline hoặc Kubernetes
postStarthook.Bạn nào dùng Redis Cluster chú ý
MOVED/ASKredirection - client library (ioredis/node-redis) handle tự động nhưng cần configenableReadyCheck: truevàmaxRetriesPerRequesthợp lý PS: the tool I meant is on labagent .tech