Skip to main content

Express Response Caching

Introduction

Response caching is a powerful technique that can significantly improve the performance and scalability of your Express applications. When properly implemented, caching reduces the computational load on your server by storing the results of expensive operations and serving them directly when the same request occurs again.

In this tutorial, we'll explore how to implement response caching in Express applications, from basic in-memory caching to more advanced techniques using HTTP headers and dedicated caching solutions.

Why Cache Responses?

Before diving into implementation, let's understand why caching is important:

  1. Improved Performance: Cached responses are delivered faster than dynamically generated ones
  2. Reduced Server Load: Fewer resources are required to serve cached content
  3. Better User Experience: Faster response times lead to a smoother experience
  4. Lower Costs: Decreased computational demands can reduce infrastructure costs

Basic Response Caching with Memory Cache

Let's start with a simple in-memory caching implementation using a JavaScript object:

javascript
const express = require('express');
const app = express();

// Simple in-memory cache
const cache = {};

app.get('/api/data', (req, res) => {
const cacheKey = req.originalUrl;

// Check if response is in cache
if (cache[cacheKey]) {
console.log('Serving from cache');
return res.json(cache[cacheKey]);
}

// If not in cache, generate data
console.log('Generating fresh response');
const data = {
message: "This is expensive data",
timestamp: new Date().toISOString(),
randomValue: Math.random()
};

// Store in cache
cache[cacheKey] = data;

res.json(data);
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

How it works:

  1. We create a simple cache object to store responses
  2. For each request, we check if we have a cached response for the URL
  3. If found, we return the cached response immediately
  4. If not found, we generate the response, store it in the cache, and then return it

The problem with this approach:

While simple, this approach has several limitations:

  • No cache expiration
  • Memory grows unbounded
  • Cache is lost when the server restarts
  • No way to clear specific cache entries

Creating a Cache Middleware

To make our caching solution more reusable, let's create a middleware:

javascript
const express = require('express');
const app = express();

function cacheMiddleware(duration) {
const cache = new Map();

return (req, res, next) => {
// Only cache GET requests
if (req.method !== 'GET') {
return next();
}

const key = req.originalUrl;
const cachedResponse = cache.get(key);

if (cachedResponse) {
const { data, timestamp } = cachedResponse;
const now = Date.now();

// Check if cache has expired
if (now - timestamp < duration) {
console.log(`Cache hit for ${key}`);
return res.json(data);
} else {
// Cache expired
cache.delete(key);
}
}

// Store original send method
const originalSend = res.json;

// Override res.json method
res.json = function(body) {
// Store in cache
cache.set(key, {
data: body,
timestamp: Date.now()
});

// Call original method
return originalSend.call(this, body);
};

next();
};
}

// Apply middleware to specific routes
app.get('/api/products', cacheMiddleware(60000), (req, res) => {
// Simulate database query
console.log('Fetching products from database...');

// Simulate delay
setTimeout(() => {
const products = [
{ id: 1, name: 'Laptop', price: 999 },
{ id: 2, name: 'Smartphone', price: 699 },
{ id: 3, name: 'Tablet', price: 399 }
];

res.json(products);
}, 500);
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

How this middleware works:

  1. The middleware function accepts a duration parameter that specifies how long (in milliseconds) responses should be cached
  2. We only cache GET requests, as other HTTP methods typically modify data
  3. We use a Map instead of a plain object for better performance
  4. We store both the response data and a timestamp
  5. We override the res.json method to capture and store the response
  6. Cached responses are automatically invalidated after the specified duration

HTTP Cache Headers

While server-side caching is useful, HTTP already provides a standardized caching mechanism through cache headers. This enables both browsers and CDNs to cache responses:

javascript
const express = require('express');
const app = express();

// Middleware for setting cache headers
function setCacheControl(maxAge) {
return (req, res, next) => {
// Set cache-control header
res.set('Cache-Control', `public, max-age=${maxAge}`);
next();
};
}

// Routes with different cache settings
app.get('/api/frequently-changed', setCacheControl(60), (req, res) => {
// Content that changes frequently - cache for 1 minute (60 seconds)
res.json({ data: 'This data changes frequently', timestamp: Date.now() });
});

app.get('/api/rarely-changed', setCacheControl(86400), (req, res) => {
// Content that changes infrequently - cache for 1 day (86400 seconds)
res.json({ data: 'This data rarely changes', timestamp: Date.now() });
});

app.get('/api/no-cache', (req, res) => {
// Content that should never be cached
res.set('Cache-Control', 'no-store');
res.json({ data: 'This is always fresh', timestamp: Date.now() });
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Common Cache-Control Directives:

  • public: Response can be cached by any cache
  • private: Response can be cached only by browser, not intermediate caches
  • no-cache: Response can be stored but must be validated before using
  • no-store: Response must not be cached at all
  • max-age: Number of seconds the response is considered fresh

Using ETag for Validation

ETags provide a way to validate if cached content is still fresh without downloading it again:

javascript
const express = require('express');
const crypto = require('crypto');
const app = express();

app.get('/api/content/:id', (req, res) => {
const id = req.params.id;

// Simulate getting data from database
const data = { id, content: `Content for ID ${id}`, timestamp: Date.now() };

// Create a hash of the response data to use as ETag
const etag = crypto
.createHash('md5')
.update(JSON.stringify(data))
.digest('hex');

// Set ETag header
res.set('ETag', etag);

// Check if client sent If-None-Match header and it matches our ETag
const ifNoneMatch = req.header('If-None-Match');
if (ifNoneMatch === etag) {
// Content hasn't changed, return 304 Not Modified without body
return res.status(304).end();
}

// Otherwise, send the full response
res.json(data);
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

How ETags work:

  1. The server generates a unique hash (ETag) for each response
  2. When a client makes subsequent requests, it sends the ETag in the If-None-Match header
  3. If the content hasn't changed, the server responds with a 304 status (Not Modified)
  4. The client then uses its cached version, saving bandwidth

Using redis for Distributed Caching

For applications running on multiple servers, an in-memory cache won't be sufficient. Let's use Redis as a shared caching solution:

javascript
const express = require('express');
const redis = require('redis');
const { promisify } = require('util');
const app = express();

// Connect to Redis
const client = redis.createClient({
host: 'localhost',
port: 6379
});

// Convert Redis callbacks to promises
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);

// Handle Redis connection errors
client.on('error', (err) => {
console.error('Redis error:', err);
});

// Middleware for Redis caching
async function redisCache(req, res, next) {
// Only cache GET requests
if (req.method !== 'GET') {
return next();
}

const key = `cache:${req.originalUrl}`;

try {
// Check if key exists in Redis
const cachedData = await getAsync(key);

if (cachedData) {
console.log(`Cache hit for ${req.originalUrl}`);
return res.json(JSON.parse(cachedData));
}

// Store original send method
const originalJson = res.json;

// Override res.json method
res.json = function(body) {
// Store in Redis (with 60 second expiration)
setAsync(key, JSON.stringify(body), 'EX', 60)
.catch(err => console.error('Redis cache error:', err));

// Call original method
return originalJson.call(this, body);
};

next();
} catch (error) {
console.error('Redis cache middleware error:', error);
next();
}
}

// Apply middleware to all routes or specific ones
app.get('/api/users', redisCache, async (req, res) => {
console.log('Fetching users from database...');

// Simulate database query
await new Promise(resolve => setTimeout(resolve, 500));

const users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
{ id: 3, name: 'Charlie', email: '[email protected]' }
];

res.json(users);
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Benefits of Redis caching:

  1. Shared cache: All server instances access the same cache
  2. Persistence: Redis can be configured to persist to disk
  3. Automatic expiration: Built-in time-to-live functionality
  4. Better memory management: Offloads memory usage from your application server

Using express-cache-middleware Library

Instead of writing your own caching logic, you can use existing libraries like express-cache-middleware:

javascript
const express = require('express');
const cacheManager = require('cache-manager');
const expressCacheMiddleware = require('express-cache-middleware');
const app = express();

// Create a memory cache with 100 max items and 10-minute TTL
const memoryCache = cacheManager.caching({
store: 'memory',
max: 100,
ttl: 600 /* seconds */
});

// Create the middleware
const cacheMiddleware = new expressCacheMiddleware({
cacheManager: memoryCache
});

// Initialize cache middleware
cacheMiddleware.attach(app);

// Route with caching enabled
// This will be automatically cached
app.get('/api/cached-data', (req, res) => {
console.log('Processing request...');

// Simulate expensive operation
const data = {
items: Array.from({ length: 10 }, (_, i) => ({
id: i,
value: Math.random()
})),
timestamp: new Date().toISOString()
};

res.json(data);
});

// Route with caching disabled
app.get('/api/fresh-data', (req, res) => {
// Disable cache for this route
res.cacheControl = {
noCache: true
};

res.json({
message: 'This data is always fresh',
timestamp: new Date().toISOString()
});
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Best Practices for Express Response Caching

  1. Cache selectively: Not all responses should be cached
  2. Set appropriate TTLs: Match cache duration to how frequently data changes
  3. Use Vary header: If responses vary based on headers (like Accept-Language)
  4. Consider query parameters: They affect cache keys
  5. Monitor cache performance: Track hit/miss ratios
  6. Include cache busting: For when data updates before cache expiration
  7. Be aware of private data: Don't cache personalized or sensitive information

Real-world Example: Caching API Responses

Here's a complete example combining several techniques for a news API endpoint:

javascript
const express = require('express');
const axios = require('axios');
const cacheManager = require('cache-manager');
const redisStore = require('cache-manager-redis-store');
const app = express();

// Create a tiered cache with memory and redis
const memoryCache = cacheManager.caching({ store: 'memory', max: 100, ttl: 60 });
const redisCache = cacheManager.caching({
store: redisStore,
host: 'localhost',
port: 6379,
ttl: 600
});

const multiCache = cacheManager.multiCaching([memoryCache, redisCache]);
const getFromCacheAsync = (key) => {
return new Promise((resolve, reject) => {
multiCache.get(key, (err, result) => {
if (err) return reject(err);
resolve(result);
});
});
};

const setCacheAsync = (key, data, ttl) => {
return new Promise((resolve, reject) => {
multiCache.set(key, data, { ttl }, (err) => {
if (err) return reject(err);
resolve();
});
});
};

// Middleware to check cache before making external API calls
app.get('/api/news', async (req, res) => {
try {
const category = req.query.category || 'general';
const cacheKey = `news:${category}`;

// Try to get from cache first
const cachedNews = await getFromCacheAsync(cacheKey);

if (cachedNews) {
// Set header to indicate cache hit
res.set('X-Cache', 'HIT');
return res.json(cachedNews);
}

// Cache miss, fetch from external API
console.log(`Fetching ${category} news from external API...`);
const response = await axios.get(`https://newsapi.org/v2/top-headlines`, {
params: {
category,
country: 'us',
apiKey: process.env.NEWS_API_KEY
}
});

const news = response.data;

// Set appropriate cache duration based on category
let cacheTTL = 300; // 5 minutes default
if (category === 'technology') cacheTTL = 1800; // 30 minutes
if (category === 'business') cacheTTL = 600; // 10 minutes

// Store in cache
await setCacheAsync(cacheKey, news, cacheTTL);

// Set HTTP cache headers
res.set('Cache-Control', `public, max-age=${cacheTTL}`);
res.set('X-Cache', 'MISS');

return res.json(news);
} catch (error) {
console.error('Error fetching news:', error);
res.status(500).json({ error: 'Failed to fetch news' });
}
});

// Cache invalidation endpoint
app.post('/api/cache/invalidate', (req, res) => {
const { category } = req.body;

if (category) {
const cacheKey = `news:${category}`;
multiCache.del(cacheKey, (err) => {
if (err) {
return res.status(500).json({ error: 'Failed to invalidate cache' });
}
res.json({ message: `Cache for ${category} invalidated` });
});
} else {
// Clear all caches
multiCache.reset(() => {
res.json({ message: 'All caches cleared' });
});
}
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Summary

Response caching is a powerful technique that can dramatically improve the performance and scalability of your Express applications. We've covered:

  • Basic in-memory caching
  • Creating reusable cache middleware
  • HTTP cache headers and ETags
  • Redis for distributed caching
  • Using third-party libraries for caching
  • Best practices and real-world examples

By implementing appropriate caching strategies, you can reduce server load, improve response times, and enhance the overall user experience of your application.

Additional Resources

Exercises

  1. Implement a caching middleware that varies its behavior based on the authenticated user (private vs. public data).
  2. Create a route that serves images with appropriate cache headers.
  3. Build a system that automatically invalidates cache when database records are updated.
  4. Implement a stale-while-revalidate pattern where expired cache is served while a new version is fetched in the background.
  5. Add cache analytics to track hit rates and optimally adjust TTL values based on actual usage patterns.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)