Caching static assets with ETag and Cache-Control headers isn’t about making things faster; it’s about making the network disappear for most users.

Let’s watch a browser fetch a style.css file that’s been cached.

Scenario:

  1. First Request: Browser asks for /static/style.css. Server responds with:

    HTTP/1.1 200 OK
    Content-Type: text/css
    Last-Modified: Tue, 15 Nov 2023 10:00:00 GMT
    ETag: "abcdef12345"
    Cache-Control: public, max-age=31536000, immutable
    

    The browser downloads the file and stores it locally, along with the ETag and Cache-Control headers. The max-age=31536000 (one year) tells the browser it can reuse this file for up to a year without asking the server. immutable is a strong hint that this file will never change.

  2. Second Request (after a short time): Browser needs /static/style.css again. Because it’s within the max-age period, the browser doesn’t send a request to the server. It serves style.css directly from its local cache. No network round trip, instant load.

  3. Third Request (after a long time, but still within max-age): Browser needs /static/style.css. It still has the file cached. If the immutable flag is present, the browser might not even check if it’s still valid until the max-age is completely expired. This is the power of immutable.

  4. Request after max-age expires (or if immutable wasn’t used and the browser revalidates): Browser needs /static/style.css. The max-age has passed. Now, the browser needs to revalidate if its cached copy is still fresh. It sends a conditional GET request:

    GET /static/style.css HTTP/1.1
    Host: example.com
    If-None-Match: "abcdef12345"
    If-Modified-Since: Tue, 15 Nov 2023 10:00:00 GMT
    

    The browser sends its cached ETag (If-None-Match) and Last-Modified date (If-Modified-Since) to the server.

  5. Server Response to Revalidation:

    • If the file hasn’t changed: The server sees the ETag matches and the Last-Modified date is the same. It responds with:
      HTTP/1.1 304 Not Modified
      
      The browser receives this, knows its cached copy is still good, and serves it from local storage. This is a tiny response, saving bandwidth and time.
    • If the file has changed: The server would respond with a 200 OK and the new version of style.css, along with new ETag and Last-Modified headers.

The mental model:

  • ETag (Entity Tag): This is a unique identifier for a specific version of a resource. Think of it like a file’s content hash (e.g., MD5, SHA-1). If the file content changes, the ETag must change. Web servers often generate this based on the file’s modification timestamp and size, or a true content hash.
  • Last-Modified: This is the timestamp of when the resource was last changed on the server. It’s a fallback or complementary mechanism to ETag.
  • Cache-Control: This is the primary directive for controlling caching.
    • public: Allows caching by intermediate proxies (like CDNs) and the browser.
    • private: Only allows caching by the user’s browser.
    • max-age=<seconds>: How long the resource is considered "fresh" in seconds. 31536000 is one year.
    • no-cache: The resource can be cached, but the browser must revalidate with the origin server every time before using it.
    • no-store: The resource must not be cached anywhere.
    • immutable: A strong hint to the browser that the resource will never change. If used with max-age, the browser can often skip revalidation entirely until the max-age expires. This is crucial for versioned assets (e.g., style.a1b2c3d4.css).

When to use what:

  • Static Assets (CSS, JS, Images, Fonts): These are prime candidates for aggressive caching. Use Cache-Control: public, max-age=<large_number>, immutable. Generate unique filenames (e.g., style.a1b2c3d4.css) when you deploy new versions. This allows you to set immutable and a very long max-age because the URL itself signifies a new version.
  • HTML Pages: These often contain dynamic content or link to assets that might have changed. Be more conservative. Cache-Control: public, max-age=600 (10 minutes) is common, or even no-cache if the content changes very frequently.
  • API Responses: Depends entirely on the data. If it’s user-specific and changes often, private, max-age=0 or no-cache. If it’s public, relatively static data (like a list of countries), you might use public, max-age=3600.

The true magic of ETag and Cache-Control lies in the 304 Not Modified response. When a browser uses If-None-Match or If-Modified-Since and the server responds with 304, it means the entire response body was skipped. The browser just uses its local copy. This saves significant bandwidth and reduces server load drastically for repeated requests.

The most misunderstood aspect is the interaction between immutable and max-age. When you set Cache-Control: public, max-age=31536000, immutable for a file like /static/style.a1b2c3d4.css, you’re telling the browser: "This file will never change. You can keep it for a year, and honestly, you probably don’t even need to ask me if it’s still valid until that year is up." This is a powerful signal that allows browsers to skip network checks altogether for the vast majority of the cache’s lifetime, leading to near-instantaneous loads on subsequent visits.

The next thing you’ll grapple with is how to generate those versioned filenames automatically during your build process.

Want structured learning?

Take the full Express course →