Intro
Your backend is fast. Your database queries are optimized. You've minimized your JavaScript bundles and compressed your images.
But your users are still downloading the same CSS file every single time they visit your site. They are re-fetching API responses that haven't changed in hours. Every page load makes unnecessary network requests that could have been avoided.
The problem? You're not leveraging HTTP caching headers.
HTTP caching is one of the most powerful performance optimizations available to web developers, yet it's surprisingly misunderstood. When configured correctly, it can dramatically reduce server load, decrease bandwidth costs, and make your application feel instantaneous to users.
And the best part? It requires no changes to your frontend code. It's all about telling the browser: "You already have this. Don't ask me again."
This guide covers the essential caching headers you need to know, how to implement them in Node.js/Express.
Let's go!!.
What is Caching?
Before we dive into HTTP-specific caching, let us establish what caching actually means.
Caching is the practice of storing copies of data in a temporary storage location so that future requests for that data can be served faster.
An analogy? Instead of walking to the library every time you need to reference a book, you photocopy the pages you need and keep them at your desk. The next time you need that information, you just look at your copy instead of making another trip.
In web development, caching happens at multiple levels:
- Browser Cache (Client-Side): The user's browser stores copies of files (CSS, JavaScript, images, API responses) locally on their device. When they revisit your site, the browser can serve these files from disk instead of downloading them again.
- CDN Cache (Edge): Content Delivery Networks store copies of your static assets on servers distributed around the world. When a user in Tokyo requests your site hosted in New York, the CDN serves files from a server closer to Tokyo.
- Server Cache (Backend): Your server might cache database query results, rendered HTML, or computed values in memory (Redis, Memcached) to avoid expensive recalculations.
- Database Cache: Databases cache frequently accessed data in memory to avoid slow disk reads.
For this article, let's focus on the first level: browser caching controlled by HTTP headers.
Why HTTP Caching Matters
Consider a typical website visit:
Without caching:
User visits your site, browser downloads: HTML (50KB), CSS (100KB), JavaScript (300KB), images (2MB) making it a total of 2.45MB downloaded. If the user clicks to another page, browser downloads everything again: 2.45MB. If the user refreshes the page, browser downloads everything again: 2.45MB.
With proper caching:
User visits your site, browser downloads: 2.45MB on first visit, user clicks to another page, browser uses cached files: 0KB downloaded (just new HTML). If the user refreshes the page, browser uses cached files: 0KB downloaded.
The impact:
- Faster page loads (files served from local disk, not network).
- Reduced server load (fewer requests hitting your backend).
- Lower bandwidth costs (less data transferred).
- Better user experience (pages feel instant).
The Two Types of HTTP Caching
HTTP caching works in two ways:
Freshness-based caching (Expiration):
Here, the server tells a browser that the file is fresh for a particular point of time. The browser stores the file and serves it directly from cache without making any network request until the expiration time. The headers involved areCache-Control,Expires.Validation-based caching (Conditional Requests)
The server tells the browser: "Here's the file and a fingerprint (ETag). Next time you need this file, send me the fingerprint. If it hasn't changed, I will tell you to use your cached version."
The browser still makes a request, but if nothing changed, the server responds with "304 Not Modified" and no file data, saving bandwidth. The headers involved areETag,If-None-Match,Last-Modified,If-Modified-Since.
You can (and often should) use both types together.
Here's a simple example:
The first visit
Browser -> Server: GET /style.css
Server -> Browser: 200 OK
Cache-Control: max-age=86400
ETag: "abc123"
[CSS file content]
Second Visit (within 24hours)
Browser -> Server: (no request made, serves from cache)
Third Visit (after 24hours)
Browser -> Server: GET /style.css
If-None-Match: "abc123"
Server -> Browser: 304 Not Modified
(no file content sent)
The browser knows the file is still valid and uses its cached copy.
How HTTP Caching Works: The Request/Response Cycle
Let's trace through exactly what happens during HTTP requests and how caching headers control browser behavior.
The Basic Flow Without Caching
When you visit a website without any caching headers:
1. Browser: "I need /app.js"
-> Makes network request to server
2. Server: "Here's app.js"
-> Sends 200 OK with file content (500KB)
3. Browser: "I need /app.js again" (user refreshes page)
-> Makes network request to server
4. Server: "Here's app.js again"
-> Sends 200 OK with file content (500KB)
Every request downloads the full file. Wasteful.
The Flow With Freshness-Based Caching
When the server sends Cache-Control: max-age=3600:
1. Browser: "I need /app.js"
-> Makes network request to server
2. Server: "Here's app.js, and it's fresh for 1 hour"
-> Sends 200 OK
-> Cache-Control: max-age=3600
-> File content (500KB)
3. Browser stores:
-> The file content
-> The cache time (1 hour from now)
4. Browser: "I need /app.js again" (5 minutes later)
-> Checks cache
-> Sees file is still fresh (55 minutes remaining)
-> NO NETWORK REQUEST MADE
-> Serves from disk cache (0KB transferred)
5. Browser: "I need /app.js again" (65 minutes later)
-> Checks cache
-> Sees file is stale (expired)
-> Makes network request to server
The Flow With Validation-Based Caching
When the server sends an ETag:
1. Browser: "I need /app.js"
-> Makes network request to server
2. Server: "Here's app.js with fingerprint 'abc123'"
-> Sends 200 OK
-> ETag: "abc123"
-> File content (500KB)
3. Browser stores:
-> The file content
-> The ETag "abc123"
4. Browser: "I need /app.js again"
-> Makes network request to server
-> Sends: If-None-Match: "abc123"
5a. Server checks: "Has app.js changed since 'abc123'?"
-> No, it hasn't changed
-> Sends 304 Not Modified
-> NO FILE CONTENT sent (only headers, ~200 bytes)
5b. Browser:
-> Receives 304
-> Uses cached file (0KB file transfer)
OR
5a. Server checks: "Has app.js changed since 'abc123'?"
-> Yes, it has changed (new ETag: "xyz789")
-> Sends 200 OK
→ ETag: "xyz789"
-> New file content (500KB)
5b. Browser:
-> Receives 200 with new content
-> Updates cache with new file and new ETag
The browser makes a request, but often gets a tiny 304 response instead of re-downloading the full file.
Combining Both
The best strategy uses both freshness and validation:
Cache-Control: max-age=3600
ETag: "abc123"
Why is this powerful?
1. First request:
Browser -> Server: GET /app.js
Server -> Browser: 200 OK
Cache-Control: max-age=3600
ETag: "abc123"
[500KB content]
2. Request within 1 hour:
Browser -> (no request, serves from cache)
Bandwidth used: 0KB
Server load: 0 requests
3. Request after 1 hour (file unchanged):
Browser -> Server: GET /app.js
If-None-Match: "abc123"
Server -> Browser: 304 Not Modified
Bandwidth used: ~200 bytes
Server load: 1 request (but no file read/send)
4. Request after 1 hour (file changed):
Browser -> Server: GET /app.js
If-None-Match: "abc123"
Server -> Browser: 200 OK
ETag: "xyz789"
[500KB new content]
Bandwidth used: 500KB
Server load: 1 request
You get the best of both worlds: no requests during the fresh period, and efficient validation after expiration.
Understanding Cache Storage
Where does the browser store cached files?
Memory Cache (RAM):
- Extremely fast.
- Cleared when you close the tab/browser.
- Used for resources needed during the current session.
Disk Cache (Hard Drive/SSD):
- Persistent across browser sessions.
- Survives browser restarts.
- Larger storage capacity.
The browser decides which cache to use based on various factors (file size, available memory, cache headers). You don't control this directly.
Cache-Control: The Primary Caching Header
Cache-Control is the most important caching header. It controls whether resources are cached, who can cache them, and for how long.
The Essential Directives:
max-age=
How long the browser can use the cached version without checking with the server.
Cache-Control: max-age=86400
This file is fresh for 86400 seconds (24 hours). The browser won't make any request during this time.
It is used for:
- Static assets that rarely change (CSS, JS with versioned filenames).
- Images, fonts, videos.
Example values:
- max-age=31536000: 1 year (maximum recommended for static assets).
- max-age=3600: 1 hour (good for semi-dynamic content).
- max-age=60: 1 minute (frequently changing data).
no-cache
Forces the browser to validate with the server before using the cached version, even if it's fresh.
Cache-Control: no-cache
Note: Despite the name, this does NOT prevent caching. The file is still cached, but the browser must check with the server (via ETag) before using it.
Use for:
- Content that changes frequently but you still want validation.
- HTML pages.
no-store
Prevents caching entirely. The browser must download fresh every time.
Cache-Control: no-store
Nothing is saved to cache. Every request downloads the full file.
Use for:
- Sensitive data (banking info, private user data).
- Content that should never be stored locally. Don't use for:
- Static assets (terrible for performance).
- Public content.
public:
Allows any cache (browser, CDN, proxy) to store the response.
Cache-Control: public, max-age=31536000
Use for:
- Static assets served to all users.
- Public images, CSS, JavaScript
private
Only the user's browser can cache this, not CDNs or shared caches.
Cache-Control: private, max-age=3600
Use for:
- User-specific data (API responses with personal info).
- Authenticated content.
- Personalized HTML.
must-revalidate
Once the cached version expires, the browser must check with the server before using it. Cannot serve stale content.
Cache-Control: max-age=3600, must-revalidate
Use for:
- Content where serving stale data would be problematic.
- Financial data, inventory counts.
The immutable Directive
A special directive supported by modern browsers:
Cache-Control: public, max-age=31536000, immutable
immutable tells the browser that the file won't change and doesn't need revalidation. This prevents unnecessary revalidation requests when users hit refresh.
Only use this for files with hashed/versioned filenames like app.a1b2c3.js. If the file changes, the filename changes, forcing a fresh download.
Combining Directives
You can combine multiple directives:
Cache-Control: public, max-age=31536000, immutable
This means: anyone can cache this, it's fresh for 1 year, and it will never change (immutable).
ETag & Validation: Efficient Change Detection
ETags (Entity Tags) are fingerprints for resources. They let the browser ask "has this file changed?" without downloading it again.
How ETags Work
First request:
GET /api/users
Response:
200 OK
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Cache-Control: no-cache
[response data]
The server sends the file and its ETag (usually an MD5 hash of the content).
Subsequent request:
GET /api/users
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Response (if unchanged):
304 Not Modified
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
[no response data]
The browser sends the ETag. If the content hasn't changed, the server responds with 304 Not Modified and no data. The browser uses its cached version.
If the file changed:
Response:
200 OK
ETag: "7f5c8e9a2b3d4c1f6e8a9b0c1d2e3f4a5b6c7d8e"
[new response data]
The server sends the full file with a new ETag.
Strong vs Weak ETags
Strong ETag:
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Indicates byte-for-byte identical content. Even a single character change produces a different ETag.
Weak ETag:
ETag: W/"33a64df551425fcc55e4d42a148795d9f25f89d4"
Prefixed with W/. Indicates semantically equivalent content, but bytes might differ (e.g., whitespace changes, gzip compression levels).
Use strong ETags for most cases. Weak ETags are for edge cases like dynamic compression.
Last-Modified Header (The Alternative)
Before ETags, there was Last-Modified:
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT
The browser sends this back as:
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT
If the file hasn't been modified since that timestamp, the server responds with 304 Not Modified.
ETags vs Last-Modified:
| Feature | ETag | Last-Modified |
|---|---|---|
| Precision | Exact (content-based) | 1-second granularity |
| Use case | Dynamic content, APIs | Static files |
| Performance | Requires hash calculation | Just timestamp check |
| Reliability | More accurate | Can fail with clock skew |
Use ETags for APIs and dynamic content. Use Last-Modified (or both) for static files.
When to use ETags?
- Content changes unpredictably.
- You can't set long max-age times.
- Users need reasonably fresh data.
- The cost of serving stale content is high.
Examples:
- HTML pages (need to check for updates, but don't want full re-downloads).
- API endpoints that change occasionally.
- User-generated content.
Don't use ETags when:
- You have long max-age times (no validation needed during fresh period).
- Every request needs fresh data anyway (just use
no-store). - Computing the ETag is expensive (defeats the performance benefit).
Code Examples in Node.js/Express
Let's look at code snippets on how to implement caching.
Set up Express
const express = require('express');
const app = express();
Caching Static Assets
For static files (CSS, JS, images), use aggressive caching:
app.use('/static', express.static('public', {
maxAge: '1y',
immutable: true
}));
This automatically adds Cache-Control: public, max-age=31536000, immutable
Note: Only use this for versioned filenames like app.a1b2c3.js.
API Response - Public Data
Cache public API data for 5 minutes:
app.get('/api/products', (req, res) => {
res.set('Cache-Control', 'public, max-age=300');
const products = [
{ id: 1, name: 'Product A', price: 29.99 },
{ id: 2, name: 'Product B', price: 49.99 }
];
res.json(products);
});
API Response - User-Specific Data
Cache user data privately for 10 minutes:
app.get('/api/user/profile', (req, res) => {
res.set('Cache-Control', 'private, max-age=600');
const profile = {
id: 123,
name: 'John Doe',
email: 'john@example.com'
};
res.json(profile);
});
HTML Pages - Always Validate
app.get('/', (req, res) => {
res.set('Cache-Control', 'no-cache');
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
Express automatically generates ETags. The browser will validate on each request.
Implementing ETags Manually
For more control over validation:
const crypto = require('crypto');
app.get('/api/data', (req, res) => {
const data = {
timestamp: Date.now(),
message: 'Hello World'
};
const etag = crypto
.createHash('md5')
.update(JSON.stringify(data))
.digest('hex');
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set('ETag', etag);
res.set('Cache-Control', 'no-cache');
res.json(data);
});
Preventing Cache for Sensitive Data
app.get('/api/banking/account', (req, res) => {
res.set('Cache-Control', 'no-store, private');
const account = {
balance: 15234.56,
accountNumber: '****1234'
};
res.json(account);
});
Reusable Cache Middleware
You can create a middleware to simplify setting Cache-Control.
const cache = (duration, options = {}) => {
return (req, res, next) => {
const type = options.private ? 'private' : 'public';
const directives = [type, `max-age=${duration}`];
if (options.immutable) directives.push('immutable');
if (options.mustRevalidate) directives.push('must-revalidate');
res.set('Cache-Control', directives.join(', '));
next();
};
};
app.get('/api/posts', cache(300), (req, res) => {
const posts = [
{ id: 1, title: 'First Post' },
{ id: 2, title: 'Second Post' }
];
res.json(posts);
});
app.get('/api/user/settings', cache(600, { private: true }), (req, res) => {
const settings = { theme: 'dark', notifications: true };
res.json(settings);
});
Quick Reference Table
Below is a quick reference of resource types and which type of cache directives to use.
| Resource Type | Cache-Control | Duration | Notes |
|---|---|---|---|
| Versioned CSS/JS | public, max-age=31536000, immutable | 1 year | Filename changes on update |
| Unversioned images | public, max-age=86400 | 1 day | Use shorter for frequently updated |
| HTML pages | no-cache | Validate each time | Allow caching with ETag |
| Public API (slow changes) | public, max-age=3600 | 1 hour | Adjust based on update frequency |
| Public API (frequent changes) | public, max-age=300 | 5 minutes | Good default |
| User data | private, max-age=600 | 10 minutes | Private ensures no CDN caching |
| Real-time data | public, max-age=5 | 5 seconds | Or use no-store |
| Sensitive data | no-store, private | Never | Banking, health records |
Final Thoughts
Every unnecessary network request is a missed opportunity. Every repeated download of the same CSS file wastes time and bandwidth. HTTP caching headers solve this. They tell browsers to reuse resources that haven't changed, eliminating redundant requests and making your site feel instant.
Top comments (0)