HTTP caching: setting the right response headers on an Express server

Effective HTTP caching hinges on the response headers your server sends. For modern web apps, the strategy is straightforward: versioned static assets can be cached aggressively, while HTML documents, which are never versioned, should always be revalidated with the server.

This article walks through configuring an Express-based Node.js server to serve the correct caching headers, distinguishing between versioned assets like JavaScript and CSS, and non-versioned files like index.html. We'll also verify the setup using Chrome DevTools.

The sample project's key files

The sample project structures its files around the caching problem of mixed content:

Serve HTML with conditional requests

For the HTML document, requests require an implicit check for freshness. This means attaching a Cache-Control: no-cache header to the response. In this context, no-cache does not stop caching; it instructs the browser to always revalidate the copy it holds with the server before using it. To make that revalidation efficient, also send either a Last-Modified or an ETag header, letting the server respond with a 304 Not Modified if the file is unchanged.

Express provides the etag and lastModified options within express.static() to generate the respective headers automatically. Both default to true, but they can be set explicitly to make that intention clear. The Cache-Control header requires a more custom approach via the setHeaders option.

The initial static serving configuration in server.js looks like this:

app.use(express.static('public'));

You can modify the options to explicitly enable etag and lastModified, and provide a setHeaders function that checks the file path for an HTML extension. This ensures that the conditional headers apply to all files by default, but the no-cache directive only affects the HTML documents.

app.use(express.static('public', {
  etag: true, // Just being explicit about the default.
  lastModified: true,  // Just being explicit about the default.
  setHeaders: (res, path) => {
    if (path.endsWith('.html')) {
      // All of the project's HTML files end in .html
      res.setHeader('Cache-Control', 'no-cache');
    }
  },
}));

Leverage the fingerprint for static assets

When responding to requests for "fingerprinted" or versioned URLs, like app.15261a07.js, the content is immutable. In this case, you want the opposite policy: the browser should cache the response without ever hitting the network again. This is accomplished by setting Cache-Control: max-age=31536000.

Inside the setHeaders function, expand the fallback logic to identify these unique assets. The hashes in this sample consist of exactly eight hexadecimal characters, surrounded by dots. This specific structure can be targeted with a regular expression, like new RegExp('\\.[0-9a-f]{8}\\.'). If the request URL matches, inject the long-lived max-age=31536000 header; if not, apply the no-cache policy for HTML.

Here’s how you might complete the setHeaders function to handle both cases:

app.use(express.static('public', {
  etag: true, // Just being explicit about the default.
  lastModified: true,  // Just being explicit about the default.
  setHeaders: (res, path) => {
    const hashRegExp = new RegExp('\\.[0-9a-f]{8}\\.');

    if (path.endsWith('.html')) {
      // All of the project's HTML files end in .html
      res.setHeader('Cache-Control', 'no-cache');
    } else if (hashRegExp.test(path)) {
      // If the RegExp matched, then we have a versioned URL.
      res.setHeader('Cache-Control', 'max-age=31536000');
    }
  },
}));

Confirming behavior via the DevTools Network panel

With the updates in place, the ideal way to sanity-check the server’s responses is by using Chrome's DevTools. Open the Network panel, and customize the visible columns by right-clicking any column header. Ensure the display includes Name, Status, and notably Cache-Control, ETag, and Last-Modified.

Configuring DevTools' Network panel.

After a fresh page load, you should see entries similar to these:

Network panel columns.

The row for the HTML document should show a status of 304, with headers for both ETag and Last-Modified present. Even if the server just started, the browser is told to revalidate. The 304 confirms it checked with the server and that the new HTML was unnecessary. Conversely, the versioned JavaScript and CSS should show a status of 200, but with the origin indicated as from disk cache. This confirms the browser noted the Cache-Control: max-age=31536000 directive and completely skipped a network request.

A network response status of 200.

Key takeaways

Optimizing cache behavior boils down to classifying your content. For non-versioned HTML, seek revalidation by enabling validators (ETag, Last-Modified) and returning Cache-Control: no-cache. For immutable, hash-named assets, use a distinct policy that locks the resource into the cache via Cache-Control: max-age=31536000. It’s a simple binary pattern that yields huge performance benefits, and once applied, the Network panel confirms it’s working exactly as intended.