As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Think about the last time you waited for a webpage to load. That wait, even if just a second, is what caching aims to eliminate. In my work, I've seen slow applications lose users in droves. The fix often isn't more powerful servers, but smarter data handling. Caching is that smart handling.
At its core, caching is about remembering instead of recalculating. If you need to know the answer to "5 + 5" a thousand times, you wouldn't solve it each time. You'd remember "10". A web application does the same with data, storing answers to expensive questions for quick recall later. This happens at several levels, each with its own rules.
Let's start closest to the user: their browser. Your browser is a powerful tool that can save images, scripts, and page data. You control this through HTTP headers. These are simple instructions you send from your server.
Consider a user visiting your site. Their browser requests your logo. Without caching, it downloads that same logo file every single time they load a page. That's wasteful. You can tell the browser, "Keep this logo for a year." Now it loads instantly from the user's own computer.
Here's a practical way to set those instructions using a Node.js server. This middleware function lets you define how long different types of content should be cached.
// A helper to set cache-control headers easily
function setCacheHeaders(res, duration, options = {}) {
let directives = [];
if (duration > 0) {
directives.push(`public, max-age=${duration}`);
// Allow serving slightly old data while fetching new in the background
if (options.backgroundRefresh) {
directives.push(`stale-while-revalidate=${options.backgroundRefresh}`);
}
// Allow serving old data if the server has an error
if (options.serveOnError) {
directives.push(`stale-if-error=${options.serveOnError}`);
}
} else {
// Tell the browser not to cache this at all
directives.push('no-store, no-cache, must-revalidate');
}
res.setHeader('Cache-Control', directives.join(', '));
// Add validation tags
if (options.useETag !== false) {
const etag = generateHash(res.body);
res.setHeader('ETag', etag);
}
if (options.useLastModified !== false) {
res.setHeader('Last-Modified', new Date().toUTCString());
}
}
// Applying it to different routes
app.get('/styles/main.css', (req, res) => {
// CSS files change rarely, cache for a long time
setCacheHeaders(res, 31536000); // One year
res.sendFile('path/to/main.css');
});
app.get('/api/user/profile', (req, res) => {
// User data might change, cache briefly
setCacheHeaders(res, 60, {
backgroundRefresh: 30,
serveOnError: 86400
}); // 1 minute, with safety nets
res.json(userProfile);
});
The stale-while-revalidate directive is powerful. It means the browser can show the cached data immediately, then quietly check with the server for an update in the background. The user sees something fast, and it stays current.
The next layer out is the Content Delivery Network, or CDN. Imagine your server is in New York. A user in Sydney has to wait for data to travel halfway around the world. A CDN places copies of your content in data centers globally. The Sydney user gets it from a local server in Australia.
CDNs are like a network of neighborhood libraries instead of one central archive. You can program their behavior at the "edge," close to users. Here’s an example using a Cloudflare Worker, which runs on CDN servers worldwide.
// This code runs on CDN servers around the world
addEventListener('fetch', event => {
const request = event.request;
const url = new URL(request.url);
// Handle API requests with short cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(handleApiRequest(event));
}
// Handle static assets with long cache
else if (url.pathname.startsWith('/images/')) {
event.respondWith(handleStaticRequest(event));
}
});
async function handleApiRequest(event) {
const cache = caches.default;
const cachedResponse = await cache.match(event.request);
// If found in the CDN's cache, use it
if (cachedResponse) {
// But also fetch a fresh copy in the background
event.waitUntil(
fetch(event.request).then(async freshResponse => {
// Update the CDN cache with the new response
const newHeaders = new Headers(freshResponse.headers);
newHeaders.set('Cache-Control', 'public, max-age=30');
const updatedResponse = new Response(freshResponse.body, {
status: freshResponse.status,
headers: newHeaders
});
await cache.put(event.request, updatedResponse);
}).catch(err => console.log('Background update failed:', err))
);
return cachedResponse;
}
// If not in cache, fetch from the main server
const response = await fetch(event.request);
const responseForCache = response.clone();
const headers = new Headers(response.headers);
headers.set('Cache-Control', 'public, max-age=30');
// Store it in the CDN cache for other users in this region
await cache.put(event.request, new Response(response.body, { headers }));
return response;
}
async function handleStaticRequest(event) {
// Images, fonts, etc. can be cached at the edge for much longer
const response = await fetch(event.request);
const newResponse = new Response(response.body, response);
newResponse.headers.set('Cache-Control', 'public, max-age=604800'); // 1 week
return newResponse;
}
Now we move to the application server itself. This is where in-memory caches like Redis or Memcached shine. They store data in RAM, which is orders of magnitude faster than querying a database. I use this for session data, computed results, or common database queries.
The challenge is knowing when to clear this cache. If a user updates their profile, the old cached profile is wrong. You need a strategy. Here's a robust Redis cache wrapper I've built and refined.
class ApplicationCache {
constructor(redisClient) {
this.redis = redisClient;
this.localMemory = new Map(); // A tiny in-process cache for extreme speed
}
async remember(key, fetchFunction, seconds = 300) {
// 1. Check our app's own quick memory
const local = this.localMemory.get(key);
if (local && local.expires > Date.now()) {
return local.data;
}
// 2. Check the shared Redis cache
const cached = await this.redis.get(`app:${key}`);
if (cached !== null) {
const data = JSON.parse(cached);
// Store it locally for next time
this.localMemory.set(key, {
data,
expires: Date.now() + (seconds * 1000)
});
return data;
}
// 3. If not cached, run the function to get the data
const freshData = await fetchFunction();
// 4. Store it in Redis for all server instances to share
await this.redis.setex(`app:${key}`, seconds, JSON.stringify(freshData));
// 5. Store it locally too
this.localMemory.set(key, {
data: freshData,
expires: Date.now() + (seconds * 1000)
});
return freshData;
}
async forget(key) {
this.localMemory.delete(key);
await this.redis.del(`app:${key}`);
}
// A smart pattern: fetch and cache a user, but refresh if it's getting old
async getWithSoftRefresh(key, fetchFunction, ttl = 600) {
const cacheKey = `app:${key}`;
let data = await this.remember(key, fetchFunction, ttl);
// Check how old the Redis cache entry is
const timeToLive = await this.redis.ttl(cacheKey);
// If it's about to expire soon, refresh it before it does
if (timeToLive < 30) { // Less than 30 seconds left
console.log(`Cache for ${key} is stale, refreshing in background.`);
// Do the refresh without blocking the current request
fetchFunction().then(fresh => {
this.redis.setex(cacheKey, ttl, JSON.stringify(fresh));
this.localMemory.set(key, {
data: fresh,
expires: Date.now() + (ttl * 1000)
});
}).catch(err => {
console.error('Background refresh failed:', err);
});
}
return data;
}
}
// Using it in a user service
const cache = new ApplicationCache(redisClient);
async function getUserProfile(userId) {
return cache.remember(
`user.profile.${userId}`,
async () => {
console.log(`Fetching profile for user ${userId} from database.`);
// This is the expensive database query
return db.query('SELECT * FROM users WHERE id = ?', [userId]);
},
300 // Keep in cache for 5 minutes
);
}
// When the user updates their profile
async function updateUserProfile(userId, newData) {
// 1. Update the main database
await db.query('UPDATE users SET ? WHERE id = ?', [newData, userId]);
// 2. Clear the cached version
await cache.forget(`user.profile.${userId}`);
// 3. Also clear any lists that might include this user
await cache.forgetPattern('user.list.*');
}
This approach ensures data is fresh. The getWithSoftRefresh method is particularly useful. It returns cached data immediately but notices when that data is getting old. It then updates the cache in the background so the next request gets fresh data, without making the current user wait.
Sometimes the bottleneck is the database itself. Complex queries, even with good indexes, take time. Caching the results of these queries can dramatically reduce load. You're not just speeding up the response; you're giving your database room to breathe.
Here's how I layer a cache in front of database queries. It uses the SQL query itself as part of the cache key.
class QueryCache {
constructor(databasePool, redisClient) {
this.pool = databasePool;
this.redis = redisClient;
}
async execute(sql, params = [], options = {}) {
const ttl = options.ttl || 60; // default 1 minute
const skipCache = options.skipCache || false;
// Create a unique key from the query and its parameters
const querySignature = sql + JSON.stringify(params);
const cacheKey = `query:${hashString(querySignature)}`;
if (!skipCache) {
const cachedResult = await this.redis.get(cacheKey);
if (cachedResult) {
console.log(`Cache HIT for query: ${sql.substring(0, 50)}...`);
return JSON.parse(cachedResult);
}
console.log(`Cache MISS for query: ${sql.substring(0, 50)}...`);
}
// Execute the actual database query
const startTime = Date.now();
const result = await this.pool.query(sql, params);
const duration = Date.now() - startTime;
console.log(`Query took ${duration}ms`);
// Store the result in the cache
if (!skipCache && ttl > 0) {
await this.redis.setex(cacheKey, ttl, JSON.stringify(result.rows));
// If this query is about a specific table, tag it for easy clearing later
if (options.tag) {
await this.redis.sadd(`tags:${options.tag}`, cacheKey);
}
}
return result.rows;
}
// Clear all cached queries related to a specific table
async clearTableCache(tableName) {
const tagKey = `tags:table.${tableName}`;
const queryKeys = await this.redis.smembers(tagKey);
if (queryKeys.length > 0) {
await this.redis.del(...queryKeys);
await this.redis.del(tagKey);
}
}
}
// Example usage in an analytics dashboard
const queryCache = new QueryCache(dbPool, redis);
// This complex aggregation query might take seconds on a large dataset
async function getDashboardMetrics(companyId) {
return queryCache.execute(`
SELECT
DATE(created_at) as day,
COUNT(*) as order_count,
SUM(total_amount) as revenue,
AVG(total_amount) as average_order_value
FROM orders
WHERE company_id = $1
AND created_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY day DESC
`, [companyId], {
ttl: 300, // Cache for 5 minutes
tag: `table.orders.company.${companyId}` // Tag for invalidation
});
}
// When a new order comes in, invalidate the cache for this company's dashboard
async function createNewOrder(orderData) {
// 1. Insert into database
await dbPool.query('INSERT INTO orders ...', [...]);
// 2. Clear cached queries related to this company's orders
await queryCache.clearTableCache(`orders.company.${orderData.companyId}`);
}
Tagging cached queries is a game-changer. When you insert a new order, you know you need to clear any cached results about orders for that company. The tags let you find and clear exactly those cached items, not the entire cache.
A modern pattern I use frequently is "stale-while-revalidate" at the application level. It's the same idea as the browser header, but implemented in your API or UI logic. The user interface shows cached data immediately, then updates when fresh data arrives.
This pattern is perfect for dashboards, social media feeds, or any data where immediate updates aren't critical. React developers might know this from libraries like SWR. Here's what that principle looks like when you build it yourself.
// A simple hook for React components
function useCachedFetch(key, url, options = {}) {
const [data, setData] = useState(null);
const [isUpdating, setUpdating] = useState(false);
useEffect(() => {
// First, try to load from the browser's local storage
const stored = localStorage.getItem(`cache:${key}`);
if (stored) {
const parsed = JSON.parse(stored);
// Check if it's still reasonably fresh
if (Date.now() - parsed.timestamp < (options.maxAge || 60000)) {
setData(parsed.data);
}
}
// Then, always fetch fresh data from the network
const fetchFresh = async () => {
setUpdating(true);
try {
const response = await fetch(url);
const newData = await response.json();
setData(newData);
// Store it for next time
localStorage.setItem(`cache:${key}`, JSON.stringify({
data: newData,
timestamp: Date.now()
}));
} catch (error) {
console.error('Failed to fetch fresh data:', error);
// If fetch fails and we have no cached data, we might show an error
} finally {
setUpdating(false);
}
};
fetchFresh();
// Set up an interval to refresh periodically
const interval = setInterval(fetchFresh, options.refreshInterval || 30000);
return () => clearInterval(interval);
}, [key, url]);
return { data, isUpdating };
}
// Using it in a component
function ProductDashboard() {
const { data: products, isUpdating } = useCachedFetch(
'dashboard-products',
'/api/products/top-selling',
{ refreshInterval: 60000 } // Refresh every minute
);
return (
<div className="dashboard">
<h2>Top Selling Products {isUpdating && <span>(updating...)</span>}</h2>
{data ? (
<ProductList products={data} />
) : (
<p>Loading initial data...</p>
)}
</div>
);
}
The user sees data immediately if it exists in localStorage. A "loading" state is brief or non-existent. A subtle indicator shows when data is refreshing in the background. This creates a perception of instant speed.
Cache invalidation is famously one of the hard problems in computer science. When the source data changes, how do you ensure the cache updates or clears? I use a combination of strategies, depending on the data type.
For user-specific data, I often invalidate on write. When a user updates their settings, I immediately clear their cached profile. For more global data, like a list of product categories that rarely changes, I might use a simple time-based expiration.
Here are a few invalidation patterns I keep in my toolkit.
class CacheManager {
constructor(redis) {
this.redis = redis;
}
// Pattern 1: Invalidate on a time schedule
async setWithExpiry(key, value, ttlSeconds) {
await this.redis.setex(key, ttlSeconds, JSON.stringify(value));
}
// Pattern 2: Invalidate when underlying data changes (Write-Through)
async writeThrough(key, data, ttl, updateDatabase) {
// 1. Update the main database
const result = await updateDatabase(data);
// 2. Update the cache with the new result
await this.redis.setex(key, ttl, JSON.stringify(result));
return result;
}
// Pattern 3: Tag-based invalidation for related data sets
async setWithTags(key, value, tags, ttl) {
// Store the value
await this.redis.setex(key, ttl, JSON.stringify(value));
// For each tag, record that this key belongs to it
for (const tag of tags) {
await this.redis.sadd(`tag:${tag}`, key);
}
}
async invalidateTag(tag) {
// Get all keys associated with this tag
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
// Delete all those keys
await this.redis.del(...keys);
// Clean up the tag set itself
await this.redis.del(`tag:${tag}`);
}
}
// Pattern 4: Versioned keys - simple and effective
async getWithVersion(key, fetchFunction, ttl) {
const version = await this.redis.get(`version:${key}`) || 'v1';
const versionedKey = `${key}:${version}`;
let data = await this.redis.get(versionedKey);
if (!data) {
data = await fetchFunction();
await this.redis.setex(versionedKey, ttl, JSON.stringify(data));
} else {
data = JSON.parse(data);
}
return data;
}
async bumpVersion(key) {
const current = await this.redis.get(`version:${key}`) || 'v1';
const next = incrementVersion(current); // e.g., v1 -> v2
await this.redis.set(`version:${key}`, next);
// Old versioned keys will expire naturally via TTL
}
}
// Example: Caching a product catalog
const cache = new CacheManager(redis);
// Store product categories, tagged for easy invalidation
async function cacheProductCategories() {
const categories = await db.query('SELECT * FROM categories ORDER BY name');
await cache.setWithTags(
'product_categories',
categories,
['catalog', 'categories'], // Tags
3600 // 1 hour TTL
);
}
// When admin adds a new category
async function addNewCategory(categoryName) {
// 1. Update database
await db.query('INSERT INTO categories (name) VALUES (?)', [categoryName]);
// 2. Invalidate all cache entries tagged 'catalog'
await cache.invalidateTag('catalog');
// This clears 'product_categories' and any other catalog-related cache
}
Versioned keys are elegant. When you update your product catalog, you just change a version string from "v1" to "v2". All new requests look for "catalog:v2". The old "catalog:v1" data stays in cache until its TTL expires, serving any slow clients that haven't updated. There's no race condition.
When your application runs on multiple servers, you face a new challenge: distributed cache coordination. Server A might have cached a user's profile. Server B might not. If the user updates their profile via Server B, Server A's cache is now stale.
You need a way for servers to talk about cache changes. Redis's pub/sub feature is perfect for this.
class DistributedCache {
constructor(redis, instanceId) {
this.redis = redis;
this.instanceId = instanceId;
this.localStore = new Map(); // Small in-memory cache per server
// Set up a listener for cache invalidation messages from other servers
this.setupCrossServerInvalidation();
}
async setupCrossServerInvalidation() {
const subscriber = this.redis.duplicate();
await subscriber.subscribe('cache-invalidation');
subscriber.on('message', (channel, message) => {
const { senderId, action, key } = JSON.parse(message);
// Ignore messages from ourselves
if (senderId === this.instanceId) return;
if (action === 'delete') {
this.localStore.delete(key);
console.log(`Cleared local cache for ${key} on instruction from server ${senderId}`);
}
});
}
async get(key, fetchFunction, ttl = 300) {
// First, check this server's own quick memory
const local = this.localStore.get(key);
if (local && local.expires > Date.now()) {
return local.data;
}
// Check the shared Redis cache (all servers can see this)
const shared = await this.redis.get(key);
if (shared !== null) {
const data = JSON.parse(shared);
// Store it locally for faster access next time
this.localStore.set(key, {
data,
expires: Date.now() + (ttl * 1000)
});
return data;
}
// If not cached anywhere, fetch it
const data = await fetchFunction();
// Store in shared Redis
await this.redis.setex(key, ttl, JSON.stringify(data));
// Store locally
this.localStore.set(key, {
data,
expires: Date.now() + (ttl * 1000)
});
return data;
}
async set(key, value, ttl = 300) {
// Update shared Redis
await this.redis.setex(key, ttl, JSON.stringify(value));
// Update local store
this.localStore.set(key, {
data: value,
expires: Date.now() + (ttl * 1000)
});
// Tell other servers to update their local caches
await this.redis.publish('cache-invalidation', JSON.stringify({
senderId: this.instanceId,
action: 'update',
key,
ttl
}));
}
async delete(key) {
// Remove from shared Redis
await this.redis.del(key);
// Remove from local store
this.localStore.delete(key);
// Tell other servers to do the same
await this.redis.publish('cache-invalidation', JSON.stringify({
senderId: this.instanceId,
action: 'delete',
key
}));
}
}
// On Server A
const cacheA = new DistributedCache(redis, 'server-a');
// On Server B
const cacheB = new DistributedCache(redis, 'server-b');
// User updates profile via Server A
await cacheA.delete(`user:${userId}`);
// Server B automatically receives a message to delete its local copy
// The next request to Server B will fetch fresh data from Redis or database
This system keeps multiple servers in sync. Each server maintains a small local cache for blistering speed. The shared Redis cache acts as the source of truth. Pub/sub messages handle coordination. It's a balance between speed and consistency.
The real art of caching lies in the strategy, not the technology. You must ask questions about each piece of data. How often does it change? How critical is freshness? What happens if stale data is shown for a few seconds?
I typically categorize data into tiers:
Tier 1: Never cache. Real-time financial transactions, security tokens, anything where staleness is dangerous.
Tier 2: Cache with aggressive invalidation. User sessions, shopping cart contents. Cache for seconds or minutes, but clear immediately on change.
Tier 3: Cache with time-based expiration. Product listings, news articles, blog posts. A 5-minute or 1-hour staleness is acceptable. Invalidate on edit.
Tier 4: Cache for a long time. Static assets, CSS, JavaScript, images that have versioned filenames. Cache for weeks or a year.
The performance gains from thoughtful caching are not incremental; they are transformative. A database query that takes 2 seconds might be reduced to 10 milliseconds from cache. A page that loaded in 3 seconds might load in 300 milliseconds. Users notice this difference. They stay engaged. They return.
Start simple. Add caching headers to your static files. Put a CDN in front of your site. Add Redis to cache common database queries. Measure the results. You'll see the load on your database drop and your response times improve. Then, layer in more sophisticated patterns as needed.
Caching is not an afterthought. It's a fundamental part of designing modern web applications. It turns technical constraints into user delight. That's a trade-off worth making.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)