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 (0)