Redis & Advanced Caching

HTTP Caching

16 min Lesson 8 of 30

HTTP Caching

HTTP caching is one of the most powerful and underutilized performance optimization techniques. By leveraging browser and CDN caches through proper HTTP headers, you can dramatically reduce server load and improve user experience.

Cache-Control Header

The Cache-Control header is the primary mechanism for controlling cache behavior:

// Express.js example
app.get('/api/products', (req, res) => {
  res.set('Cache-Control', 'public, max-age=3600');
  res.json(products);
});

Cache-Control Directives:

  • public - Response can be cached by any cache (browser, CDN, proxy)
  • private - Response can only be cached by the browser (not CDN/proxy)
  • no-cache - Must revalidate with server before using cached response
  • no-store - Do not cache at all (sensitive data)
  • max-age=N - Cache is fresh for N seconds
  • s-maxage=N - Like max-age but only for shared caches (CDN)
  • must-revalidate - Must not use stale cache, must revalidate
  • immutable - Resource will never change (perfect for versioned assets)

Common Caching Strategies

// Static assets (CSS, JS, images with version hash)
// These files never change (new version = new filename)
app.use('/static', express.static('public', {
  maxAge: '1y', // 1 year
  immutable: true
}));

// API responses that change occasionally
app.get('/api/config', (req, res) => {
  res.set('Cache-Control', 'public, max-age=300'); // 5 minutes
  res.json(config);
});

// User-specific data
app.get('/api/user/profile', (req, res) => {
  res.set('Cache-Control', 'private, max-age=60'); // 1 minute, browser only
  res.json(userProfile);
});

// Sensitive data
app.get('/api/user/credit-card', (req, res) => {
  res.set('Cache-Control', 'no-store'); // Never cache
  res.json(sensitiveData);
});

// Dynamic content that should be revalidated
app.get('/api/news', (req, res) => {
  res.set('Cache-Control', 'public, max-age=60, must-revalidate');
  res.json(news);
});
Tip: Use public, max-age=31536000, immutable for static assets with content-based filenames (e.g., app.a3f2b1.js). This provides maximum caching benefits.

ETag (Entity Tag)

ETags enable conditional requests - the server sends a unique identifier for each version of a resource:

const crypto = require('crypto');

app.get('/api/products', async (req, res) => {
  const products = await db.products.findAll();
  const content = JSON.stringify(products);

  // Generate ETag from content hash
  const etag = crypto
    .createHash('md5')
    .update(content)
    .digest('hex');

  // Check if client has current version
  if (req.headers['if-none-match'] === etag) {
    // Content hasn't changed
    return res.status(304).end(); // Not Modified
  }

  // Content changed, send new version
  res.set({
    'ETag': etag,
    'Cache-Control': 'public, max-age=60'
  });
  res.json(products);
});
Note: ETags are perfect for API responses where you can't predict when data will change but want to avoid sending unchanged data.

Last-Modified Header

Similar to ETag but uses timestamps instead of content hashes:

app.get('/api/article/:id', async (req, res) => {
  const article = await db.articles.findById(req.params.id);
  const lastModified = article.updated_at;

  // Parse If-Modified-Since header
  const ifModifiedSince = req.headers['if-modified-since'];
  if (ifModifiedSince) {
    const clientDate = new Date(ifModifiedSince);
    const serverDate = new Date(lastModified);

    if (serverDate <= clientDate) {
      return res.status(304).end(); // Not Modified
    }
  }

  res.set({
    'Last-Modified': lastModified.toUTCString(),
    'Cache-Control': 'public, max-age=300'
  });
  res.json(article);
});

Vary Header

The Vary header tells caches that the response depends on certain request headers:

// Response varies by Accept-Language
app.get('/api/messages', (req, res) => {
  const lang = req.headers['accept-language']?.split(',')[0] || 'en';
  const messages = getMessagesForLanguage(lang);

  res.set({
    'Cache-Control': 'public, max-age=3600',
    'Vary': 'Accept-Language' // Cache separately per language
  });
  res.json(messages);
});

// Response varies by Accept-Encoding
app.get('/api/large-data', (req, res) => {
  res.set({
    'Cache-Control': 'public, max-age=86400',
    'Vary': 'Accept-Encoding' // Cache compressed and uncompressed separately
  });
  res.json(largeData);
});

// Multiple vary headers
app.get('/api/content', (req, res) => {
  res.set({
    'Cache-Control': 'public, max-age=1800',
    'Vary': 'Accept-Language, Accept-Encoding'
  });
  res.json(content);
});
Warning: Using Vary: Cookie or Vary: Authorization can make CDN caching ineffective. Use these sparingly and consider alternative approaches for personalized content.

Express Middleware for HTTP Caching

Create reusable middleware for common caching patterns:

// Generic cache middleware
function cache(seconds, options = {}) {
  return (req, res, next) => {
    const { isPublic = true, mustRevalidate = false, vary } = options;

    let cacheControl = isPublic ? 'public' : 'private';
    cacheControl += `, max-age=${seconds}`;
    if (mustRevalidate) cacheControl += ', must-revalidate';

    res.set('Cache-Control', cacheControl);
    if (vary) res.set('Vary', vary);

    next();
  };
}

// ETag middleware
function etag() {
  return (req, res, next) => {
    const originalJson = res.json.bind(res);

    res.json = function(data) {
      const content = JSON.stringify(data);
      const hash = crypto
        .createHash('md5')
        .update(content)
        .digest('hex');

      if (req.headers['if-none-match'] === hash) {
        return res.status(304).end();
      }

      res.set('ETag', hash);
      return originalJson(data);
    };

    next();
  };
}

// Usage
app.get('/api/products',
  cache(300), // 5 minutes
  etag(),
  async (req, res) => {
    const products = await db.products.findAll();
    res.json(products);
  }
);

app.get('/api/user/profile',
  cache(60, { isPublic: false }), // 1 minute, private
  async (req, res) => {
    const profile = await getUserProfile(req.userId);
    res.json(profile);
  }
);

CDN Integration

Optimize cache headers for CDN (CloudFlare, Fastly, AWS CloudFront):

// Different cache durations for browser vs CDN
app.get('/api/static/config', (req, res) => {
  res.set({
    // Browsers cache for 5 minutes
    // CDN caches for 1 hour
    'Cache-Control': 'public, max-age=300, s-maxage=3600',
    'CDN-Cache-Control': 'max-age=3600' // CloudFlare-specific
  });
  res.json(config);
});

// Bypass CDN for personalized content
app.get('/api/recommendations', (req, res) => {
  res.set({
    'Cache-Control': 'private, max-age=60',
    'Surrogate-Control': 'no-store' // Tell CDN not to cache
  });
  res.json(recommendations);
});

Stale-While-Revalidate

Serve stale content while fetching fresh data in the background:

app.get('/api/feed', (req, res) => {
  res.set({
    // Cache for 30 seconds
    // Allow serving stale for 60 seconds while revalidating
    'Cache-Control': 'public, max-age=30, stale-while-revalidate=60'
  });
  res.json(feed);
});
Note: stale-while-revalidate provides the best user experience - users always get instant responses while the cache updates in the background.

Cache Debugging

Add debugging headers to understand cache behavior:

function cacheDebug() {
  return (req, res, next) => {
    const start = Date.now();

    res.on('finish', () => {
      const duration = Date.now() - start;
      res.set({
        'X-Cache-Status': res.statusCode === 304 ? 'HIT' : 'MISS',
        'X-Response-Time': `${duration}ms`
      });
    });

    next();
  };
}

// Enable in development
if (process.env.NODE_ENV === 'development') {
  app.use(cacheDebug());
}
Exercise: Create an Express API with three endpoints: one for public static data (cache 1 hour), one for user-specific data (private cache 5 minutes), and one for frequently changing data using ETags. Test with curl or Postman to verify cache headers and 304 responses.