nginx sub_filter Strips Cache Validators: Why SPA Entries Need Explicit Cache-Control

This article explains how nginx's sub_filter module removes Last-Modified and ETag headers during HTML replacement, why heuristic caching can still serve stale SPA entry pages, and how to explicitly set Cache-Control: no-cache while preserving security headers and retaining old hashed assets during deployments.

Ops Development & AI Practice
Ops Development & AI Practice
Ops Development & AI Practice
nginx sub_filter Strips Cache Validators: Why SPA Entries Need Explicit Cache-Control

What sub_filter Does

The sub_filter directive, part of the ngx_http_sub_module (source file ngx_http_sub_filter_module.c), performs case-insensitive string replacement in response bodies. It does not parse HTML or use regular expressions. A common use case is injecting a site-specific domain into shared front-end builds at serve time:

<meta property="og:image" content="__SITE_ORIGIN__/share.png">
<meta name="twitter:image" content="__SITE_ORIGIN__/share.png">

nginx replaces the placeholder before sending the HTML:

location / {
    sub_filter '__SITE_ORIGIN__' 'https://$site_host';
    sub_filter_once off;
    try_files $uri $uri.html /index.html;
}
sub_filter_once

defaults to on (replace only the first match per rule); off is used here because the placeholder appears in two tags. The module processes text/html by default and is not compiled into nginx by default — verify with nginx -V. The variable $site_host must be defined safely (e.g., from an allow-list) to avoid injecting attacker-controlled domains into share tags.

Why HTML Response Headers Change

Because the response body may be altered, the original static file's length and validators no longer match the final output. According to current nginx source code, for any main request that enters the sub_filter processing pipeline, the header filter unconditionally removes Content-Length; by default it also removes Last-Modified and ETag.

Two important details:

The cleanup happens during the header-filter phase, before the body is scanned. Therefore, headers are stripped even if the placeholder is not actually found — it is not true that headers are removed only when a replacement occurs.

Enabling sub_filter_last_modified on does not simply restore all original validators. The directive preserves the original Last-Modified and converts any strong ETag (e.g., "abc") into a weak one ( W/"abc"). It does not generate a new ETag based on the modified body, and Content-Length remains removed.

A weak ETag does not guarantee byte-for-byte equivalence. If the replacement value changes while the underlying file stays the same, the validator may not reflect that change. sub_filter does not remove Cache-Control. The absence of a cache policy on HTML responses must be traced to add_header configuration, inheritance rules, or other settings — not to the body replacement itself.

No Last-Modified Does Not Mean No Caching

HTTP caching involves two distinct mechanisms:

Freshness — how long a cached response can be reused without checking the server (e.g., Cache-Control: max-age=600).

Validators — ETag and Last-Modified that allow a cache to ask the server whether its stored copy is still valid, potentially receiving a 304 Not Modified.

When explicit freshness is absent, caches may apply heuristic freshness. RFC 9111 (§4.2.2) notes that a cache can use Last-Modified; a common heuristic is 10% of the time since last modification, but the standard does not mandate this algorithm for all caches. Thus, a file modified ten days ago might be considered fresh for one day by some caches — this is only an example, not universal browser behavior.

Conversely, removing Last-Modified does not guarantee that browsers or proxies will revalidate on every request . It merely removes one common heuristic input; different cache implementations may still serve stale content using other strategies. Relying on this side effect as a caching strategy is risky: later removing the body replacement or changing validator settings could silently alter caching behavior without any explicit configuration expressing the original intent.

Why SPAs Are Especially Vulnerable

Single-page application entry HTML typically references content-hashed assets:

<!-- example filename, not from a real project -->
<script src="/assets/main.oldhash.js"></script>

After a new deployment, the new HTML points to new filenames. If a browser still holds the old HTML while the old asset files have been deleted from the server, those asset requests fail. The failure occurs specifically when both conditions hold: the cached HTML is stale and the referenced resources have been removed. Merely having a cache entry does not guarantee a blank screen.

Diagram showing stale HTML referencing deleted hashed resources leading to load failures
Diagram showing stale HTML referencing deleted hashed resources leading to load failures

Beyond cache policy, deployments should retain old hashed assets for a grace period. Already-open pages may later load dynamic modules from the old version; the entry HTML's no-cache cannot retroactively fix those subsequent requests.

Make the HTML Cache Policy Explicit

For public SPA entry points that must fetch new versions promptly, set an explicit directive:

# Example config — replace paths with actual deployment paths.
location / {
    # File must exist and contain this project's security header config.
    include /etc/nginx/snippets/security-headers.conf;
    add_header Cache-Control "no-cache" always;

    sub_filter '__SITE_ORIGIN__' 'https://$site_host';
    sub_filter_once off;
    try_files $uri $uri.html /index.html;
}
no-cache

permits storing the response but requires successful validation before reuse. It differs from no-store, which forbids storage entirely. When no validator is available, the cache typically re-fetches the full HTML; for small entry files this cost is usually acceptable (RFC 9111 on no-cache).

The include for security headers is deliberate: under nginx's default inheritance rules, defining any add_header in the current location blocks inheritance of add_header from parent contexts. Adding a cache header could therefore unintentionally drop CSP and other security headers. Since nginx 1.29.3, add_header_inherit merge can adjust this behavior, but verify the running version and existing configuration to avoid duplicate headers (nginx official header docs).

Hashed assets can use long-term caching in a separate resource location:

add_header Cache-Control "public, max-age=31536000, immutable";

This requires that the same path never serves different content. Missing resources must return a genuine 404 — not fall back to HTML — otherwise the fallback could be cached long-term. Long-cache policies should apply only to successful resource responses.

Pages containing personal data or requiring authentication need separate private or no-store policies; do not reuse the public entry configuration.

How to Verify the Configuration

First, confirm the running nginx version, compile-time options, and configuration syntax:

nginx -v
nginx -V
nginx -t

Then inspect the actual response with a GET request (example domain and path):

curl -sS -D headers.txt -o page.html https://example.com/app

Verify that the HTML returns Cache-Control: no-cache, security headers are still present, and the placeholder has been replaced. Check /index.html, deep SPA routes, and hashed assets separately, as they may match different location blocks.

When a CDN is in front, observe both the origin and the public edge. Newly added headers do not automatically invalidate already-fresh cached entries; existing caches must be purged using platform-specific mechanisms.

If replacement appears to fail, check compression. sub_filter does not decompress gzipped bodies on its own. When proxying upstream, request uncompressed content with proxy_set_header Accept-Encoding "";. If gzip_static is enabled, ensure the HTML is not served from a pre-compressed file that bypasses the replacement filter. Always validate both headers and actual body content — do not rely on configuration text alone.

When the configuration is generated via a shell heredoc, distinguish two variable layers: nginx variables (e.g., $site_host) must be escaped as \$site_host so they survive to runtime, while shell variables that should be expanded immediately are left unescaped. This depends on whether the heredoc uses a quoted delimiter; do not mechanically backslash every dollar sign.

What This Incident Reveals

The fact that a browser restart resolved the issue suggests client-side state was involved, but it does not by itself prove the stale HTML came from memory cache, nor does it rule out Service Workers, page restoration, proxies, or other factors. Root-cause analysis still requires examining the Network log, response sources, and resource requests at the time of failure.

Therefore, the stale page is not attributed solely to

sub_filter</sub>. The clearer takeaway is: <strong>body replacement and cache policy must be expressed independently.</strong> <code>sub_filter

modifies content; Cache-Control defines how the response may be reused; deployment strategy ensures that old pages can still locate the resources they reference.

References

nginx: ngx_http_sub_module directive documentation

nginx: sub_filter module official source code

nginx: add_header and response header inheritance rules

RFC 9111: HTTP Caching

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

DeploymentSPAnginxCache ControlETagHTTP cachingLast-Modifiedsub_filter
Ops Development & AI Practice
Written by

Ops Development & AI Practice

DevSecOps engineer sharing experiences and insights on AI, Web3, and Claude code development. Aims to help solve technical challenges, improve development efficiency, and grow through community interaction. Feel free to comment and discuss.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.