Skip to content

Cache Headers: Cache-Control, Expires, and Validation Directives

DodaTech Updated 2026-06-28 4 min read

In this tutorial, you will learn about Cache Headers: Cache. We cover key concepts, practical examples, and best practices to help you master this topic.

HTTP cache headers are the primary mechanism for controlling how browsers, CDNs, and proxy caches store and reuse responses. By setting the right directives, you define freshness, validation, and cacheability policies without writing any application code.

flowchart LR
    A[Server Response] --> B{Cache-Control Present?}
    B -->|Yes| C{public or private?}
    C -->|public| D[CDN and Browser Cache]
    C -->|private| E[Browser Cache Only]
    B -->|No| F[Check Expires Header]
    F -->|Has Expires| G[Use Expires Date]
    F -->|No Expires| H[Heuristic Freshness]
    D --> I{max-age elapsed?}
    I -->|No| J[Serve Fresh Cache]
    I -->|Yes| K[Revalidate with Origin]

What You'll Learn

  • Every Cache-Control directive: public, private, no-cache, no-store, max-age, s-maxage, must-revalidate, proxy-revalidate
  • ETag and Last-Modified validation mechanisms
  • How to combine headers for optimal caching behavior

Why It Matters

Well-configured cache headers can reduce origin server load by 70-90% with zero application changes. Misconfigured headers are the most common cause of caching bugs — either serving stale content or not caching at all.

Real-World Use

A SaaS dashboard sets Cache-Control: public, max-age=0, must-revalidate on HTML pages so browsers always revalidate, but sets Cache-Control: public, max-age=31536000, immutable on versioned JS bundles so they are cached for a year without revalidation.

Validation with ETags

const crypto = require('crypto');

app.get('/api/resource', async (req, res) => {
  const data = await getData();
  const etag = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex');

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

  res.set('ETag', etag);
  res.set('Cache-Control', 'public, max-age=0, must-revalidate');
  res.json(data);
});

Expected output:

Browser sends If-None-Match header on repeat requests. If data unchanged, server returns 304 Not Modified with empty body.

Validation with Last-Modified

app.get('/api/articles', async (req, res) => {
  const articles = await getArticles();
  const lastModified = new Date(articles[0].updatedAt).toUTCString();

  if (req.headers['if-modified-since'] === lastModified) {
    res.status(304).end();
    return;
  }

  res.set('Last-Modified', lastModified);
  res.set('Cache-Control', 'public, max-age=0');
  res.json(articles);
});

Expected output:

Browser sends If-Modified-Since. If articles haven't changed since that date, server returns 304.

Granular Cache-Control Directives for API

app.get('/api/user/:id/profile', (req, res) => {
  const userId = req.params.id;

  if (req.user.id !== userId) {
    res.set('Cache-Control', 'public, max-age=60');
  } else {
    res.set('Cache-Control', 'private, max-age=300');
  }

  res.json(getProfile(userId));
});

Expected output:

Other users' profile pages are publicly cacheable for 60s. The user's own profile is private and cached only in the browser for 300s.

Common Mistakes

  • Setting Cache-Control: no-store everywhere because you don't understand caching, losing all performance benefits.
  • Using max-age without public or private, causing unexpected behavior in CDNs.
  • Forgetting to set must-revalidate for time-sensitive content that should never serve stale.
  • Setting Expires header to a past date to disable caching — use Cache-Control: no-cache instead.
  • Not including the Vary header when caching responses that differ by Accept-Encoding, Accept-Language, or Cookie.

Practice Questions

  1. What is the difference between no-cache and no-store?
  2. How does s-maxage differ from max-age?
  3. What is a conditional request and which headers enable it?
  4. Why would you set Cache-Control: immutable on a JavaScript bundle?
  5. What does the Vary: Accept-Encoding header tell a CDN?

Challenge

Design a caching Strategy for a multi-language website. Use the Vary header to cache separate versions per language. Set different Cache-Control policies for the homepage (revalidate always), article pages (stale while revalidate 1 hour), and API data (max-age 60s).

FAQ

What is the difference between Cache-Control: public and private?

public allows any cache (browser, CDN, proxy) to store the response. private restricts caching to the browser only, preventing CDNs from caching user-specific content.

How does max-age=0 differ from no-cache?

Both require revalidation, but max-age=0 explicitly sets the freshness lifetime to zero, while no-cache tells caches not to serve cached responses without revalidation. They are functionally similar.

What is the difference between ETag and Last-Modified?

ETag is a content-based hash (strong validator). Last-Modified is a timestamp (weak validator). ETag is more precise; Last-Modified is simpler. Use both for best coverage.

Should I set Cache-Control on API responses?

Yes. Even private API responses benefit from short max-age values to avoid redundant network requests. Use no-store only for truly sensitive or non-replayable requests.

What does the Vary header do?

Vary tells caches that the response may differ based on certain request headers. For example, Vary: Accept-Encoding ensures compressed and uncompressed versions are cached separately.

Mini Project

Create an Express server with three endpoints configured with different cache policies: /static/* with max-age=31536000 and immutable, /api/public with max-age=60 and stale-while-revalidate=300, /api/user with private max-age=0. Use curl to verify the Cache-Control headers and conditional request behavior with If-None-Match.

What's Next

Continue with ETags for a deep dive into entity tag validation and strong vs. weak comparison.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro