DEV Community

Boehner
Boehner

Posted on

HTTP Caching in Node.js APIs: ETag, Cache-Control, and stale-while-revalidate Explained

HTTP caching is one of those topics where everyone knows it exists, few people implement it correctly, and almost nobody talks about the failure modes.

This is the guide I wish existed when I was building my first API that needed to stay fast under load.

What HTTP caching actually is

HTTP caching isn't one mechanism. It's a family of related headers that tell clients and proxies what to do with a response. Getting them wrong means either stale data reaching users or your server hammered on every request.

The three headers you need to understand:

Cache-Control: max-age=300, stale-while-revalidate=60
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d"
Last-Modified: Wed, 19 Mar 2026 12:00:00 GMT
Enter fullscreen mode Exit fullscreen mode

These do very different things. Let's break them down.

Cache-Control

The most important header. It tells the browser (and any CDN/proxy in between) how long to cache the response.

// The directives you'll actually use:

res.setHeader('Cache-Control', 'public, max-age=300');
// Public = CDNs can cache this. max-age=300 = valid for 5 minutes.

res.setHeader('Cache-Control', 'private, max-age=0, no-store');
// Private = only the browser can cache (not CDNs). No-store = don't cache at all.

res.setHeader('Cache-Control', 'public, max-age=3600, stale-while-revalidate=60');
// Serve stale for 60s while revalidating in the background. The key to fast APIs.
Enter fullscreen mode Exit fullscreen mode

The stale-while-revalidate directive is wildly underused. It says: "serve the cached version immediately (fast!), but kick off a background request to refresh it." Users get instant responses; data stays fresh. This pattern alone can cut your P99 latency by 60-80% on cached routes.

ETags - conditional requests

An ETag is a fingerprint for a response. If the response changes, the ETag changes. The browser sends the ETag back on subsequent requests, and your server can respond with 304 Not Modified if nothing changed - skipping the response body entirely.

const crypto = require('crypto');

function generateETag(data) {
  return '"' + crypto
    .createHash('md5')
    .update(JSON.stringify(data))
    .digest('hex') + '"';
}

app.get('/api/products', async (req, res) => {
  const products = await db.getProducts();
  const etag = generateETag(products);

  // Check if client already has this version
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end(); // No body - saves bandwidth
  }

  res.setHeader('ETag', etag);
  res.setHeader('Cache-Control', 'public, max-age=60');
  res.json(products);
});
Enter fullscreen mode Exit fullscreen mode

A 304 response has no body. For a /api/products endpoint returning 50KB of JSON, that's 50KB saved on every cache hit. At scale, this matters.

A complete caching middleware in 45 lines

Here's a reusable Express middleware that handles both ETag and Cache-Control for you:

// cache-middleware.js
const crypto = require('crypto');

/**
 * HTTP caching middleware for Express APIs.
 * Handles ETag validation and Cache-Control headers automatically.
 *
 * Usage: app.get('/api/data', cacheFor(300), handler)
 * Or:    app.use('/api/public', cacheFor(60, { staleWhileRevalidate: 30 }))
 */
function cacheFor(maxAgeSeconds, options = {}) {
  const { staleWhileRevalidate = 0, visibility = 'public' } = options;

  return (req, res, next) => {
    // Capture the original json() method
    const originalJson = res.json.bind(res);

    res.json = function (data) {
      const etag = '"' + crypto
        .createHash('sha1')
        .update(JSON.stringify(data))
        .digest('hex').slice(0, 16) + '"';

      // Check conditional request
      if (req.headers['if-none-match'] === etag) {
        return res.status(304).end();
      }

      // Set caching headers
      let cacheControl = `${visibility}, max-age=${maxAgeSeconds}`;
      if (staleWhileRevalidate > 0) {
        cacheControl += `, stale-while-revalidate=${staleWhileRevalidate}`;
      }

      res.setHeader('ETag', etag);
      res.setHeader('Cache-Control', cacheControl);
      res.setHeader('Vary', 'Accept-Encoding');

      return originalJson(data);
    };

    next();
  };
}

module.exports = { cacheFor };
Enter fullscreen mode Exit fullscreen mode

Use it like this:

const { cacheFor } = require('./cache-middleware');

// Cache product list for 5 minutes, serve stale for 60s while revalidating
app.get('/api/products', cacheFor(300, { staleWhileRevalidate: 60 }), async (req, res) => {
  const products = await db.getProducts();
  res.json(products);
});

// Cache user profile for 60 seconds, private (user-specific)
app.get('/api/profile', authenticate, cacheFor(60, { visibility: 'private' }), async (req, res) => {
  const profile = await db.getUserProfile(req.user.id);
  res.json(profile);
});

// Never cache sensitive endpoints
app.post('/api/payment', (req, res) => {
  res.setHeader('Cache-Control', 'no-store');
  // ...
});
Enter fullscreen mode Exit fullscreen mode

When to cache what

Not all endpoints should be cached the same way. Here's my mental model:

Endpoint type Cache-Control Why
Static reference data (countries, categories) public, max-age=86400 Changes rarely, same for everyone
Product/content lists public, max-age=300, stale-while-revalidate=60 Mostly shared, tolerate 5-min staleness
User-specific data private, max-age=60 Only browser cache, not CDN
Authenticated API calls private, max-age=30 Short TTL, no CDN
Payment/checkout no-store Never cache
Search results public, max-age=30 or no-cache Depends on staleness tolerance
Webhook endpoints no-store POST requests shouldn't cache

The failure modes

Caching authenticated responses publicly. If a user-specific response leaks into a CDN cache, the next person to request that URL gets someone else's data. Always use private for authenticated responses. Never use public for anything that varies by user.

Not varying on the right headers. If your API returns different responses based on Accept-Language or Authorization, you must include those in Vary:

res.setHeader('Vary', 'Accept-Language, Accept-Encoding');
Enter fullscreen mode Exit fullscreen mode

Without this, a CDN might serve a cached English response to a French user.

Stale cache after a deploy. Your API returns a 5-minute cached response. You deploy a bug fix. Users see the broken version for up to 5 minutes. Solutions:

  • Use short TTLs (max-age=60) with stale-while-revalidate
  • Include a version hash in your API path (/api/v2/products)
  • Use ETags so clients always validate before assuming cached data is valid

Caching error responses. res.json({ error: 'Service unavailable' }) should never be cached. Check for errors before setting cache headers:

res.json = function (data) {
  // Don't cache error responses
  if (data && data.error) {
    res.setHeader('Cache-Control', 'no-store');
    return originalJson(data);
  }
  // ... caching logic
};
Enter fullscreen mode Exit fullscreen mode

Testing your cache headers

The fastest way to verify your caching is working:

# First request - should be a cache MISS (no ETag stored yet)
curl -I "https://your-api.com/api/products"
# Look for: ETag: "abc123", Cache-Control: public, max-age=300

# Second request with the ETag - should return 304
curl -I "https://your-api.com/api/products" \
  -H 'If-None-Match: "abc123"'
# Look for: HTTP/2 304 - no body returned
Enter fullscreen mode Exit fullscreen mode

Chrome DevTools ? Network tab ? check the "Size" column. A "304" response shows "(from cache)" or a tiny byte size - that's confirmation it's working.

The 5-minute implementation

Add this to your Express API right now:

  1. Install nothing - this uses only Node built-ins
  2. Add cache-middleware.js from above
  3. Apply cacheFor(300, { staleWhileRevalidate: 60 }) to your highest-traffic read endpoints
  4. Apply cacheFor(60, { visibility: 'private' }) to user-specific endpoints
  5. Add no-store to payment and auth endpoints
  6. Test with curl -I and verify ETags

For a typical API serving 10,000 requests/day with 40% cache hit rate, this reduces database load by ~4,000 queries/day. For free.


I build APIs and developer tools. Currently working on SnapAPI - a web intelligence API for developers and AI pipelines.

Top comments (0)