Web Development

HTTP Caching for a Personal Website — Keep Pages Current and Assets Fast

HTTP Caching for a Personal Website — Keep Pages Current and Assets Fast

A personal website can feel fast on a second visit for a simple reason: the browser may already have some of its files. But the same mechanism can also keep an old stylesheet alive, show yesterday's HTML, or place a personalized response where a shared cache can reuse it. The useful question is therefore not "Should this site use caching?" It is "Which responses may be reused, by whom, and for how long?"

HTTP already has a vocabulary for answering that question. A practical policy does not require memorizing every directive. It does require separating freshness from validation, understanding the misleading name no-cache, and treating a URL as a promise about how its content changes.

What an HTTP cache actually does

RFC 9111 defines a cache as a local store of response messages together with the subsystem that controls their storage, retrieval, and deletion. Reusing a suitable response can shorten response time and avoid transferring the same bytes again. It can also spare the origin from processing another equivalent request.

There are two boundaries to keep in mind. A private cache is dedicated to one user, commonly inside a browser. A shared cache can reuse a response for more than one user; reverse proxies and CDNs are familiar examples. A response that is safe in one person's browser is not automatically safe in a shared cache.

A stored response is fresh while its age remains within its freshness lifetime. A fresh response can normally be reused without contacting the origin. Once it becomes stale, it is not necessarily discarded. The cache can ask the origin whether its copy is still valid. RFC 9111 calls that process validation or revalidation.

This distinction matters because caching has two different ways to save work:

  • Freshness can avoid a network request entirely for a period.
  • Validation still contacts the server, but can avoid sending the response content again when nothing changed.

Freshness and validation are different controls

The response directive max-age supplies an explicit freshness lifetime in seconds. For example, the following response may remain fresh for one hour:

Cache-Control: max-age=3600

This does not guarantee that every cache will store the response. HTTP caching is optional, and implementations can apply local storage and eviction policies. The header says when a stored response may be reused; it is not a command that reserves disk space.

Validation uses metadata that identifies a representation. An origin can send an ETag, a Last-Modified date, or both. Later, a client can return that metadata in If-None-Match or If-Modified-Since. If the selected representation has not changed, the server can answer with 304 Not Modified and no message content. The detailed validator and conditional-request semantics live in RFC 9110. When both relevant request conditions are present, If-None-Match takes precedence over If-Modified-Since.

A validator is not a freshness lifetime. It gives the cache a cheaper question to ask after a response needs checking. Conversely, a long max-age can suppress that question until the response becomes stale. Good policies often use both mechanisms, but for different jobs.

The directive whose name causes the most confusion

Cache-Control: no-cache does not mean "do not store." It means that a stored response must not be reused without successful validation. That makes it useful for a stable page URL whose content may change: the cache can retain a copy, but the origin keeps a chance to confirm it before reuse.

Cache-Control: no-cache
ETag: "page-revision-42"

Cache-Control: no-store is the directive that tells caches not to store the response. It is appropriate when retaining the response itself is unacceptable, although applying it everywhere sacrifices useful browser behavior and transfer savings. It also does not reach backward in time and erase an older response already stored for that URL. The practical differences and tradeoffs are explained well in the MDN HTTP caching guide.

private addresses a different question. It prevents a shared cache from storing the complete response, while still permitting storage in a private cache. A personalized account page might therefore use:

Cache-Control: private, no-cache

That is a sensible starting point, not a universal answer. A response containing especially sensitive information may justify no-store. A public article does not become private merely because a visitor happens to have a cookie. The policy should follow the actual response content and privacy boundary, not a vague fear of caching.

A small policy based on three resource types

It is easier to reason about caching when resources are grouped by how they change. For a small CMS or personal site, three groups cover much of the surface.

1. Public HTML with a stable URL

An article URL should continue to identify that article after an edit. Giving its HTML a very long freshness lifetime means the origin cannot correct the cached copy until that lifetime ends, unless a managed cache provides a separate purge mechanism. A conservative policy is:

Cache-Control: no-cache
ETag: "article-252-revision-3"
Last-Modified: Sat, 22 Aug 2026 17:16:09 GMT

The values above are illustrative, not observed headers from this site. In a real CMS, validators must change when the selected representation changes. Generating an ETag from a trustworthy content revision can be cheaper than hashing a fully rendered response on every request, but the right implementation depends on templates, localization, compression, and other representation choices.

2. Versioned static assets

A stylesheet named app.css can change while keeping the same URL. A file named app.a81c9e.css, by contrast, can use a new URL whenever its content changes. Because the cache key contains at least the request method and target URI, the new filename produces a new lookup rather than competing with the old bytes.

Cache-Control: public, max-age=2592000, immutable

Thirty days here is an example, not a magic number. The important condition is stronger: a URL marked immutable should not later serve different content. If the deployment process overwrites files in place, drop immutable and choose a shorter lifetime. Long-lived caching works because versioned URLs make change explicit, not because one duration is universally optimal.

3. Personalized or sensitive responses

Pages that differ by authenticated user should not be available for general reuse by a shared cache. private, no-cache can preserve private-browser validation. no-store can be the stricter choice when storage is itself the concern. Authentication also has specific shared-cache rules in RFC 9111, so making authenticated content publicly cacheable should be a deliberate design with a clear cache key, not an accidental header inherited from static assets.

Vary is part of correctness, not decoration

A URL is not always enough to select a representation. The same URL might return gzip or Brotli content according to Accept-Encoding, or different language content according to Accept-Language. The Vary response field tells caches which request fields must match before a stored response can be reused.

Vary: Accept-Encoding

Without the appropriate Vary, a cache can select the wrong representation. But varying on a field with many possible values can fragment the cache and reduce reuse. MDN specifically cautions against casually varying on User-Agent; feature detection or stable URLs are often cleaner. A multilingual site can also avoid language negotiation entirely by giving each translation its own URL, while still varying on encoding when the server compresses responses dynamically.

A conservative NGINX starting point

NGINX can add response fields with add_header. The following sketch assumes that files under /static/ have versioned names and that public HTML is handled by the catch-all location:

location /static/ {
    add_header Cache-Control "public, max-age=2592000, immutable" always;
}

location / {
    add_header Cache-Control "no-cache" always;
    try_files $uri $uri/ /index.php?$query_string;
}

This is not a drop-in promise. The official NGINX headers module documentation notes that add_header directives are inherited from the previous configuration level only when the current level defines none of its own. The always parameter also changes the default status-code limitation. A nested location, PHP handler, or application-generated Cache-Control field can therefore alter or duplicate the result.

Choose one authoritative layer for each policy, inspect the complete rendered NGINX configuration, and test the actual routes. In particular, do not apply the static policy to user uploads or asset URLs that can be overwritten without changing their names.

Verify behavior instead of trusting the config file

A header in a configuration file is only an intention. Inspect the origin response for representative HTML, assets, error pages, and authenticated routes:

curl -sSI https://example.com/article/
curl -sSI https://example.com/static/app.a81c9e.css

Look for Cache-Control, ETag, Last-Modified, Vary, and, when a shared cache is involved, Age. Then take an actual validator from the first response and make a conditional request:

curl -sSI \
  -H 'If-None-Match: "validator-from-the-response"' \
  https://example.com/article/

A 304 is expected only when the validator still matches and the route implements that condition. If the content changed, a full response is correct. Also test after a deployment: confirm that HTML points to the new versioned asset URL and that the old versioned URL still returns its original bytes for as long as clients may cache it.

What this policy does not solve

HTTP headers cannot actively purge every browser or intermediary that already holds a fresh response. Managed CDNs may expose purge controls, but those are product-specific operations outside the standard caching model. Service workers add another programmable cache with its own lifecycle. Neither should be mixed into an initial policy without separate tests.

Caching also does not repair an unstable deployment process. Long lifetimes magnify mistakes when a supposedly immutable URL is overwritten. Validators become unreliable when they do not account for every representation change. Broad Vary fields can waste storage, while missing ones can return incorrect content. These are reasons to roll out by resource class and observe behavior, not reasons to disable caching everywhere.

Conclusion

A sound caching policy begins with the resource's change model and privacy boundary. Stable public HTML can be stored but revalidated. Truly versioned assets can stay fresh much longer. Personalized responses need a private boundary, and sensitive responses may need no storage at all. Validators save content transfer; freshness can save the request itself; Vary keeps reuse attached to the right representation.

The cautious approach is not to find the longest possible max-age. It is to make each URL an honest promise, set explicit rules, and verify the responses that users and intermediaries actually receive.

References