DEV Community

Cover image for HTTP Caching Headers: The Performance Optimization You're Probably Missing
Ugochukwu Nebolisa
Ugochukwu Nebolisa

Posted on

HTTP Caching Headers: The Performance Optimization You're Probably Missing

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:

  1. 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.
  2. 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.
  3. Server Cache (Backend): Your server might cache database query results, rendered HTML, or computed values in memory (Redis, Memcached) to avoid expensive recalculations.
  4. 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:

  1. 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 are Cache-Control, Expires.

  2. 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 are ETag, 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]
Enter fullscreen mode Exit fullscreen mode

Second Visit (within 24hours)

Browser -> Server: (no request made, serves from cache)
Enter fullscreen mode Exit fullscreen mode

Third Visit (after 24hours)

Browser -> Server: GET /style.css
                   If-None-Match: "abc123"
Server -> Browser: 304 Not Modified
                   (no file content sent)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

The server sends the full file with a new ETag.

Strong vs Weak ETags

Strong ETag:

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Enter fullscreen mode Exit fullscreen mode

Indicates byte-for-byte identical content. Even a single character change produces a different ETag.

Weak ETag:

ETag: W/"33a64df551425fcc55e4d42a148795d9f25f89d4"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The browser sends this back as:

If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Caching Static Assets
For static files (CSS, JS, images), use aggressive caching:

app.use('/static', express.static('public', {
  maxAge: '1y',
  immutable: true
}));
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

HTML Pages - Always Validate

app.get('/', (req, res) => {
  res.set('Cache-Control', 'no-cache');
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
Enter fullscreen mode Exit fullscreen mode

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);
});

Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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)