Learn how to make Express 5 APIs faster with HTTP cache headers, ETags and a Redis cache-aside layer, and how to keep cached data fresh with TTLs and invalidation.

Bishal Biswas
Self Learner
What you will learn
- What caching is and the two places you can cache: the client side and the server side
- How to control browser and CDN caching with the
Cache-Controlheader and ETags - How to connect Express to Redis and use the cache-aside pattern
- How to keep cached data fresh with expiry times and invalidation
- What should never be cached
What is caching?
Caching means keeping a copy of an answer so you do not have to work it out again. Your browser caches images. Your brain caches your phone number. Servers cache expensive results.
Think of a coffee shop. Making each cup to order is slow. If the shop knows lots of people want plain black coffee at 8am, it brews a big pot and pours from it. The pot is the cache. When the coffee gets old, they throw it out and brew a fresh one. That is expiry.
There are two places to cache in a web app:
Browser cache / CDN Your server Database
(HTTP cache headers) --> (Redis cache) --> (slow, the "source of truth")
"I already have this" "I computed this "Let me run that big
a minute ago" query again..."
| Layer | Who stores the copy | Saves | You control it with |
|---|---|---|---|
| HTTP caching | The browser, or a CDN (a network of servers close to users) | Whole requests never reach your server | Response headers |
| Server-side caching | Your server, usually in Redis | Database queries and heavy work | Your own code |
You often use both. Let’s start with the one that needs no extra software.
HTTP caching with Cache-Control
The Cache-Control response header tells browsers and CDNs whether they may keep a copy, and for how long.
// app.js
import express from 'express';
const app = express();
app.get('/api/countries', (req, res) => {
// Anyone may cache this for one hour (3600 seconds)
res.set('Cache-Control', 'public, max-age=3600');
res.json([{ code: 'NP', name: 'Nepal' }, { code: 'IN', name: 'India' }]);
});
app.get('/api/me', (req, res) => {
// Personal data: never store it anywhere
res.set('Cache-Control', 'no-store');
res.json({ name: 'Asha' });
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Server running on http://localhost:3000');
});
The most useful directives:
| Directive | Meaning |
|---|---|
max-age=60 |
The copy is fresh for 60 seconds |
public |
Shared caches (CDNs, proxies) may store it too |
private |
Only the user’s own browser may store it |
no-cache |
You may store it, but must check with the server before using it |
no-store |
Do not store it at all |
s-maxage=300 |
Like max-age, but only for shared caches such as CDNs |
stale-while-revalidate=30 |
After expiry, serve the old copy for 30 more seconds while fetching a new one in the background |
Warning:
no-cachedoes not mean “do not cache”. It means “check first”. Useno-storefor sensitive data such as account details or anything behind a login.
ETags: “has it changed?”
An ETag is a short fingerprint of a response body. Express adds one automatically when you use res.send() or res.json().
When the browser asks again, it sends the fingerprint back in an If-None-Match header. If the body would be the same, Express replies 304 Not Modified with an empty body. The browser reuses its copy.
See it with curl:
curl -i http://localhost:3000/api/countries
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Content-Type: application/json; charset=utf-8
ETag: W/"47-8kM1pVh1f8B8s3kz2aVb1vXk3oA"
Copy your ETag value and send it back:
curl -i http://localhost:3000/api/countries -H 'If-None-Match: W/"47-8kM1pVh1f8B8s3kz2aVb1vXk3oA"'
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=3600
ETag: W/"47-8kM1pVh1f8B8s3kz2aVb1vXk3oA"
Your ETag will be different. The W/ means it is a weak ETag, which is the Express default.
Note: A
304saves bandwidth, not server work. Your route still ran and built the full response so Express could fingerprint it. To skip the work itself, you need a server-side cache.
Caching static files
express.static() can set cache headers for you. Files whose names change when their content changes (for example app.3f9a1c.js from a build tool) can be cached for a very long time:
// app.js
app.use('/assets', express.static('dist/assets', { maxAge: '1y', immutable: true }));
app.use(express.static('public', { maxAge: '1h' }));
immutable tells the browser not even to check for a new version while the copy is fresh.
Server-side caching with Redis
Imagine a “top products” query that takes 800 milliseconds. A thousand visitors a minute means a thousand slow queries, all returning the same answer. A server-side cache runs the query once and serves the saved result to everyone else.
What is Redis?
Redis is a very fast database that keeps data in memory. It stores keys and values, like a giant shared JavaScript Map, and each key can have an expiry time. That makes it perfect for caches.
Why not just use a Map inside your app? Because:
- Every copy of your app would have its own separate
Map. - The cache would be lost on every restart or deploy.
- It would grow without limit and use up your server’s memory.
Redis runs as a separate service that all copies of your app share.
Running Redis locally
The easiest way is Docker:
docker run -d --name redis -p 6379:6379 redis:7-alpine
Then install the Node.js client:
npm install redis
Add the connection URL to your .env file:
# .env
REDIS_URL=redis://localhost:6379
Connecting
Put the connection in its own module so every route can share it:
// lib/redis.js
import { createClient } from 'redis';
export const redis = createClient({ url: process.env.REDIS_URL });
redis.on('error', (error) => {
console.error('Redis error:', error.message);
});
await redis.connect(); // top-level await works in ES modules
The cache-aside pattern
The most common caching recipe is called cache-aside. The cache sits “to the side” of your database, and your code checks it first:
request --> is it in Redis?
| yes --> return the cached copy (fast)
| no --> query the database
save the result in Redis with an expiry
return the result
Here it is in Express. The fake getTopProducts() waits 800 ms to act like a slow query.
// app.js
import express from 'express';
import { redis } from './lib/redis.js';
const app = express();
const getTopProducts = async () => {
await new Promise((resolve) => setTimeout(resolve, 800)); // pretend this is slow
return [
{ id: 1, name: 'Keyboard', sold: 1200 },
{ id: 2, name: 'Mouse', sold: 950 },
];
};
app.get('/api/products/top', async (req, res) => {
const key = 'products:top';
const cached = await redis.get(key); // null if the key does not exist
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
}
const products = await getTopProducts();
await redis.set(key, JSON.stringify(products), { EX: 60 }); // expire after 60 seconds
res.set('X-Cache', 'MISS');
res.json(products);
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Server running on http://localhost:3000');
});
Start it with your .env file loaded (Node.js 20.6+):
node --env-file=.env app.js
Call it twice and compare the time and the X-Cache header:
curl -s -o /dev/null -w '%{time_total}s\n' http://localhost:3000/api/products/top
curl -s -o /dev/null -w '%{time_total}s\n' http://localhost:3000/api/products/top
The first call takes a bit over 0.8 seconds. The second returns almost instantly, because the answer came from Redis.
Note: Redis stores strings, so you
JSON.stringifywhen saving andJSON.parsewhen reading.
A reusable helper
Writing that get / set dance in every route gets repetitive. Wrap it in a small helper:
// lib/cache.js
import { redis } from './redis.js';
export const remember = async (key, ttlSeconds, compute) => {
try {
const cached = await redis.get(key);
if (cached) return { value: JSON.parse(cached), hit: true };
} catch (error) {
console.error('Cache read failed, using the database:', error.message);
}
const value = await compute();
try {
await redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
} catch (error) {
console.error('Cache write failed:', error.message);
}
return { value, hit: false };
};
Notice the try/catch blocks. A cache is an optimisation. If Redis is down, your app should get slower, not break. Without the try/catch, Express 5 would forward the Redis error to your error handler and every request would fail with a 500.
Now routes stay short:
// routes/products.js
import { Router } from 'express';
import { remember } from '../lib/cache.js';
import { Product } from '../models/product.js';
export const productsRouter = Router();
productsRouter.get('/:id', async (req, res) => {
const { value: product, hit } = await remember(`product:${req.params.id}`, 300, () =>
Product.findById(req.params.id).lean(),
);
if (!product) return res.status(404).json({ error: 'Product not found' });
res.set('X-Cache', hit ? 'HIT' : 'MISS');
res.json(product);
});
Keeping the cache fresh
The hardest part of caching is not saving data. It is knowing when a saved copy is wrong. There are two tools.
1. Expiry (TTL)
Every key gets a TTL (time to live), like { EX: 60 }. When it runs out, Redis deletes the key and the next request rebuilds it. Pick the TTL by asking: “How old can this data be before a user would notice or care?”
| Data | Reasonable TTL |
|---|---|
| List of countries | Hours or days |
| Product details | A few minutes |
| “Top products this hour” | 30-60 seconds |
| Stock level during a flash sale | Probably do not cache |
2. Invalidation
Invalidation means deleting a cached copy as soon as the real data changes. Do it in the routes that write data:
// routes/products.js (continued)
productsRouter.patch('/:id', async (req, res) => {
const product = await Product.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!product) return res.status(404).json({ error: 'Product not found' });
await redis.del(`product:${req.params.id}`); // the old copy is now wrong
await redis.del('products:top'); // lists that include it may be wrong too
res.json(product);
});
Tip: Use a consistent naming scheme for keys, such as
type:id(product:42,user:7:orders). It makes invalidation much easier to reason about.
Caching lists with query strings
For /api/products?page=2&sort=price, the query string is part of the answer, so it must be part of the key:
// routes/products.js
productsRouter.get('/', async (req, res) => {
const page = Number(req.query.page ?? 1);
const sort = req.query.sort === 'price' ? 'price' : 'name';
const key = `products:list:page=${page}:sort=${sort}`;
const { value } = await remember(key, 60, () =>
Product.find().sort(sort).skip((page - 1) * 20).limit(20).lean(),
);
res.json(value);
});
Build the key from the validated values, not the raw query string. Otherwise ?sort=price&page=2 and ?page=2&sort=price create two different keys for the same data, and an attacker could fill your Redis with junk keys.
What not to cache
- Personal data in shared caches. Never mark per-user responses as
public. A CDN could serve one user’s data to another. - Anything that must be exact right now, such as a bank balance at checkout.
- Responses to
POST,PUT,PATCH,DELETE. Cache reads, not writes. - Errors. If the database fails, do not save the error message for 60 seconds.
Common mistakes
- Caching without an expiry. Forgotten keys live forever and the data slowly goes stale. Always set
EX. - Forgetting to invalidate on update. Users edit something and still see the old version.
- Letting a Redis outage take the whole app down. Wrap cache calls so they fall back to the database.
- Confusing
no-cacheandno-store.no-cachestill stores the response. - Caching too early. Measure first. Cache the slow, popular endpoints, not everything.
Key takeaways
- HTTP caching (
Cache-Control, ETags) stops repeat requests from reaching your server or re-downloading data. - Express adds weak ETags and answers
304 Not Modifiedautomatically forres.send()andres.json(). - Redis is a fast, shared, in-memory store; use
createClient({ url }),await client.connect(), andset(key, value, { EX: seconds }). - Cache-aside: check Redis, fall back to the database, save the result with a TTL.
- Keep data fresh with TTLs and by deleting keys when the data changes.
Practice
- Add
Cache-Control: public, max-age=300to a read-only endpoint and confirm withcurl -ithat a second request with the ETag returns304. - Use the
remember()helper to cacheGET /api/notes/:idfrom your Notes API for 120 seconds, and delete the key in your update and delete routes. - Stop the Redis container (
docker stop redis) while your app runs. Does the app keep answering? Fix it if it does not. - Log
X-Cachehits and misses for a minute of traffic and work out your hit rate (hits divided by total requests).
What’s next
Caching avoids doing work twice. Next, you will make every response smaller on the wire. Continue with Compression and Response Optimisation.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready