SPA HTTP Caching

1. Overview

The JHipster-based Angular SPAs (registration-portal, membership-ui, admin-ui) use a three-layer HTTP caching strategy to ensure users never load a stale index.html after a deployment while still benefiting from aggressive caching of fingerprinted JavaScript and CSS bundles.

This document describes the intended behaviour of the cache headers, the components that produce them, the Cloudflare interactions, the deployment release cycle, and the test procedure used to validate that the stack is working end to end.

2. The deployment breakage problem

Angular CLI production builds emit content-hashed filenames (outputHashing: "all") — every JavaScript and CSS bundle has a 16-character hex hash in its filename, for example main.4c4c9933ed3a235e.js. When any source file changes, the hash changes, and therefore the filename changes.

index.html is the only non-hashed file in the build. It references the hashed bundles by their current filenames, for example:

<script src="runtime.b02e2eac1fcc980c.js"></script>
<script src="main.4c4c9933ed3a235e.js"></script>

After a deployment, the old hashed files no longer exist on the origin — they are replaced by new hashes. Any client that still holds a stale copy of `index.html` will try to fetch the old hashed bundles, get 404s, and either see a broken page or fail to bootstrap the Angular application entirely.

The cache-control strategy therefore revolves around one principle: index.html must always be fresh, but hashed bundles can be cached forever.

3. The three-layer caching strategy

3.1. Layer 1: index.html and SPA routes (must always be fresh)

These responses carry:

Cache-Control:     no-cache
CDN-Cache-Control: no-store
Last-Modified:     <build time of the deployed jar>

The no-cache directive tells browsers they may keep a cached copy but must revalidate before using it. CDN-Cache-Control: no-store tells Cloudflare to never serve this response from its edge cache. The real Last-Modified header — set to the Maven build time — lets browsers send a lightweight If-Modified-Since revalidation request and get back 304 Not Modified when the deployment has not changed.

Paths that receive this treatment, via CacheControlFilter:

  • /index.html — the SPA entry point

  • /, /register, /event/*, and all other SPA routes (anything without a file extension that doesn’t match an API/auth/infrastructure prefix)

3.2. Layer 2: Fingerprinted static assets (cache forever)

Hashed JavaScript, CSS, SVG, PNG, font, and ico files under the SPA root carry:

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

That’s approximately four years, with the immutable directive telling the browser never to revalidate. This is safe because the filename changes on every build — the browser simply requests a different URL, which produces a cache miss and a fresh fetch.

This is handled by StaticResourcesWebConfiguration, which registers a Spring MVC ResourceHandlerRegistry mapping CACHEABLE_RESOURCE_PATTERNS (defined in RequestClassifier).

3.3. Layer 3: API, auth, infrastructure (never cache)

API endpoints (/api/), service proxies (/services/), OAuth flows (/login, /oauth2/), and management endpoints (/actuator/, /management/, /v3/, /swagger-ui/**) carry:

Cache-Control: no-store

These responses are authenticated, user-specific, and often mutating. Neither the browser nor the CDN may cache them.

4. Components

Class Role

RequestClassifier

Single source of truth for path classification. Exposes the CACHEABLE_RESOURCE_PATTERNS used by StaticResourcesWebConfiguration and the classify(path) → Category method used by filters. Categories: SPA_ENTRY, SPA_ROUTE, API, SERVICE_PROXY, AUTH, INFRASTRUCTURE, STATIC_CONTENT, STATIC_ASSET.

CacheControlFilter

Security-chain filter that sets Cache-Control (and CDN-Cache-Control for SPA categories) per request category, before the chain proceeds. Registered in SecurityConfiguration.filterChain via addFilterBefore(new CacheControlFilter(), BasicAuthenticationFilter.class).

SpaWebFilter

Security-chain filter that forwards unmapped SPA routes (/register, /event/123, …) to /index.html via the servlet request dispatcher. The forwarded request dispatches through Spring MVC and is handled by IndexHtmlController.

IndexHtmlController

REST controller with @GetMapping("/index.html"). Reads classpath:/static/index.html once at startup into a byte[], and serves it with Last-Modified set to BuildProperties.getTime(). Uses WebRequest.checkNotModified(buildTimeMillis) for conditional-request handling. Takes precedence over Spring Boot’s default ResourceHttpRequestHandler for the /index.html path.

StaticResourcesWebConfiguration

WebMvcConfigurer (prod profile only) that registers a resource handler for CACHEABLE_RESOURCE_PATTERNS with a long-lived immutable CacheControl. Source of the layer-2 cache headers.

WebConfigurer.shallowEtagHeaderFilter()

ShallowEtagHeaderFilter bean scoped to /index.html with writeWeakETag = true and DispatcherType.REQUEST + FORWARD. Provides a weak ETag header as a defence-in-depth revalidation mechanism for non-Cloudflare environments (local dev, CI tests). Cloudflare strips the ETag in production — see below — so the primary revalidation mechanism is Last-Modified.

5. Why Last-Modified and not ETag in production

The obvious first choice is ShallowEtagHeaderFilter — Spring buffers the response body, computes an MD5, and sets an ETag header. It handles conditional If-None-Match requests automatically and returns 304 when the client’s tag matches. This works perfectly at origin and in local dev.

It does not work through Cloudflare. Stage-environment testing (chain-of-evidence, verified via kubectl port-forward to the pod) proved that:

  1. The pod emits ETag: W/"010f3e4c5fc64989bd5f3614dc181914f" correctly.

  2. ingress-nginx preserves the weak ETag.

  3. Cloudflare’s response-normalisation pipeline strips ETag, Content-Length, and Accept-Ranges from full 200 HTML responses, while leaving them intact on 206 Partial Content (Range) responses. This is an undocumented Cloudflare behaviour confirmed by community reports and by the explicit strip_etags / strip_last_modified flags Cloudflare introduced in its Cache Response Rules (March 2026).

  4. Cloudflare does not strip Last-Modified from the same responses.

The underlying root of the ETag issue is the same reason Spring Boot’s default classpath Last-Modified is the Unix epoch (Thu, 01 Jan 1970 00:00:01 GMT) — reproducible-build tooling (Jib, Maven reproducible flags) zeros out file modification times on classpath resources so that the JAR itself is byte-reproducible. Spring’s ResourceHttpRequestHandler surfaces this epoch as the Last-Modified header, and a naive implementation then returns false 304 Not Modified responses because any browser’s If-Modified-Since is after the epoch. See spring-boot#24099 and spring-framework#25845 for the upstream discussion.

IndexHtmlController solves both problems by bypassing ResourceHttpRequestHandler for /index.html and emitting Last-Modified from BuildProperties.getTime() directly. Cloudflare preserves that header. Conditional revalidation works end to end.

6. The Cloudflare Email Obfuscation interaction

Cloudflare’s Email Address Obfuscation feature scans HTML responses for visible email addresses and rewrites them with a per-request randomised cipher (/cdn-cgi/l/email-protection#…​). Two side effects matter for SPA caching:

  1. The response body is different on every request — the per-request cipher means two consecutive downloads of /index.html have different MD5 hashes.

  2. Because the body is transformed, Cloudflare strips the upstream ETag header (the bytes no longer match the origin hash). This happens in addition to the general ETag-stripping behaviour described above.

The customer-friendly error UI on index.html includes mailto:[email protected] links, which triggers this rewriting. The code-level fix is to wrap the mailto: anchors with Cloudflare’s documented HTML-comment opt-out:

<!--email_off--><a href="mailto:[email protected]">[email protected]</a><!--/email_off-->

This keeps Email Obfuscation enabled zone-wide while exempting index.html specifically. With these wrappers in place, index.html byte-stable across requests even when obfuscation is on.

7. Deployment cycle: what a user’s browser experiences

Step What happens

1. First visit (fresh browser)

Browser requests / → SpaWebFilter forwards to /index.html → IndexHtmlController returns 200 OK with Last-Modified: <build time>, Cache-Control: no-cache, CDN-Cache-Control: no-store. Browser loads the referenced hashed bundles (main.HASH.js etc.) → origin returns 200 OK with Cache-Control: immutable. Browser caches the bundles.

2. Repeat visit (no deployment in between)

Browser requests / → because Cache-Control: no-cache, browser sends If-Modified-Since: <build time>. IndexHtmlController calls WebRequest.checkNotModified(), sees the timestamps match, returns 304 Not Modified. Browser uses its cached copy. All referenced bundles are still in the browser’s immutable cache — no network requests at all for the bundles.

3. After a deployment (new build time)

Browser requests / → sends If-Modified-Since: <old build time>. IndexHtmlController sees the build time has advanced, returns 200 OK with the new body (which references new hashed bundles). Browser discards its cached index.html, fetches the new one, then fetches the new hashed bundles. Old bundles remain in the immutable cache but are never referenced again — they age out naturally.

4. SPA navigation (e.g. /register)

Browser requests /register → SpaWebFilter forwards to /index.html → IndexHtmlController handles the FORWARD dispatch and returns the correct Last-Modified/body. Works the same as step 1–3 for any SPA route.

8. Validation procedure

These checks should pass on any deployment of 2.3.21 or later. The host used below is the stage host (registration-stage.myriadevents.co.za); substitute the production host for production validation. All checks use the command line so they can be scripted into a post-deployment smoke test.

8.1. Step 1: Confirm the deployed version

curl -s https://registration-stage.myriadevents.co.za/management/info | python3 -c \
  "import sys, json; d=json.loads(sys.stdin.read()); \
   print('version:', d['build']['version'], \
         'commit:', d['git']['commit']['id']['describe'], \
         'built:', d['build']['time'])"

Expected output format:

version: 2.3.21 commit: 64051d4 built: 2026-04-15T04:06:51.870Z

Note the build time — it will appear as the Last-Modified value in the following checks.

8.2. Step 2: Cache-Control and Last-Modified on /index.html

curl -sI https://registration-stage.myriadevents.co.za/index.html \
  | grep -iE "^(cache-control|cdn-cache-control|last-modified)"

Expected:

cache-control: no-cache
cdn-cache-control: no-store
last-modified: Wed, 15 Apr 2026 04:06:51 GMT

The last-modified value must match the built time from step 1 (rounded to the nearest second). A value of Thu, 01 Jan 1970 00:00:01 GMT indicates IndexHtmlController is not wired up — either the deployed artefact predates 2.3.21, or Spring’s default ResourceHttpRequestHandler is intercepting the request before the controller.

8.3. Step 3: Body byte-stability

curl -s -H 'Accept-Encoding: identity' \
     https://registration-stage.myriadevents.co.za/index.html | md5sum
curl -s -H 'Accept-Encoding: identity' \
     https://registration-stage.myriadevents.co.za/index.html | md5sum

Both commands must produce the same MD5 hash. A different hash per request means Cloudflare is rewriting the body — typically Email Obfuscation acting on a mailto: link that is not wrapped in <!--email_off-→.

To confirm the root cause when the hashes differ:

curl -s https://registration-stage.myriadevents.co.za/index.html -o /tmp/s1.html
curl -s https://registration-stage.myriadevents.co.za/index.html -o /tmp/s2.html
diff /tmp/s1.html /tmp/s2.html

Presence of /cdn-cgi/l/email-protection# in the diff confirms Email Obfuscation; wrap the offending mailto: in the HTML with <!--email_off-→ / <!--/email_off-→.

8.4. Step 4: If-Modified-Since conditional request — match (expect 304)

Use the exact Last-Modified value from step 2:

curl -sI -H 'If-Modified-Since: Wed, 15 Apr 2026 04:06:51 GMT' \
  https://registration-stage.myriadevents.co.za/index.html | head -3

Expected first line:

HTTP/2 304

A 200 OK instead of 304 means the conditional check is broken. Likely causes:

  • The date format is wrong (must be RFC 1123 GMT)

  • The deployed version predates the IndexHtmlController fix

  • An intermediate proxy is stripping If-Modified-Since on the way to origin

8.5. Step 5: If-Modified-Since before build time (expect 200)

curl -sI -H 'If-Modified-Since: Mon, 01 Jan 2020 00:00:00 GMT' \
  https://registration-stage.myriadevents.co.za/index.html | head -3

Expected:

HTTP/2 200

A date before the build time must always return a fresh response. A 304 here would indicate the classic "epoch Last-Modified`" bug from pre-2.3.21 Spring Boot behaviour: the server is treating any client timestamp as "after the origin’s Last-Modified" because the origin is still serving `Thu, 01 Jan 1970 00:00:01 GMT.

8.6. Step 6: If-Modified-Since far in the future (expect 304)

curl -sI -H 'If-Modified-Since: Thu, 01 Jan 2099 00:00:00 GMT' \
  https://registration-stage.myriadevents.co.za/index.html | head -3

Expected:

HTTP/2 304

Confirms the comparison is timestamp-based rather than exact-match.

8.7. Step 7: SPA route forward through IndexHtmlController

curl -sI https://registration-stage.myriadevents.co.za/register \
  | grep -iE "^(HTTP|cache-control|cdn-cache-control|last-modified)"

curl -sI -H 'If-Modified-Since: Wed, 15 Apr 2026 04:06:51 GMT' \
  https://registration-stage.myriadevents.co.za/register | head -3

Both should produce the same results as steps 2 and 4 for /index.html, because SpaWebFilter forwards /register to /index.html, which is then handled by IndexHtmlController on the FORWARD dispatch. A missing Last-Modified on the SPA route indicates IndexHtmlController is not registered to handle FORWARD dispatches, or SpaWebFilter is not running.

8.8. Step 8: Hashed asset immutability

Pick any hashed JavaScript reference from the index page:

JS=$(curl -s https://registration-stage.myriadevents.co.za/index.html \
     | grep -oE 'main\.[a-f0-9]+\.js' | head -1)
echo "$JS"
curl -sI "https://registration-stage.myriadevents.co.za/$JS" \
  | grep -iE "^(cache-control)"

Expected:

main.4c4c9933ed3a235e.js
cache-control: max-age=126230400, public, immutable

The immutable directive is the contract: browsers trust that this content never changes and skip all revalidation for its full lifetime. The epoch Last-Modified is irrelevant on these responses because immutable overrides any conditional-request logic.

8.9. Step 9: Cloudflare proxy status

curl -sI https://registration-stage.myriadevents.co.za/index.html \
  | grep -iE "^(server|cf-ray|cf-cache-status)"

Expected (values will vary):

cf-cache-status: DYNAMIC
server: cloudflare
cf-ray: 9ec8248b4ec0bbf0-JNB

cf-cache-status: DYNAMIC confirms Cloudflare is not serving from its edge cache — every request is proxied to origin, which is the intended behaviour for SPA entry points (CDN-Cache-Control: no-store). A value like HIT here would indicate Cloudflare is caching HTML, which would break the deployment-freshness guarantee.

8.10. Step 10: Direct-pod validation (when the full stack fails)

When the external checks above fail in ways that implicate the proxy chain — for example, the ETag header that appears at origin is missing in the Cloudflare response — hit the pod directly to isolate the layer:

export KUBECONFIG=<path-to-kubeconfig>
kubectl -n event-stage get pods -l app.kubernetes.io/name=registration-portal
kubectl -n event-stage port-forward svc/stage-registration-portal 18080:80 &
curl -sI http://localhost:18080/index.html

This bypasses both Cloudflare and ingress-nginx. Any header you see here represents what the Spring application is actually emitting, so differences with the external response isolate which layer is modifying the traffic. A separate sanity check via --resolve to the ingress LB IP isolates ingress-nginx’s contribution.

9. Troubleshooting

Symptom Likely cause and fix

Last-Modified: Thu, 01 Jan 1970 00:00:01 GMT

IndexHtmlController is not handling the request. Verify the deployed artefact is 2.3.21 or later, that BuildProperties is on the classpath (spring-boot-maven-plugin with <goal>build-info</goal> is configured), and that no security rule is blocking /index.html.

Last-Modified missing entirely

Something is stripping the header after origin. Check for a Cloudflare Transform Rule or Response Rule that touches Last-Modified. The ingress-nginx layer does not strip Last-Modified in default configurations.

If-Modified-Since returns 200 instead of 304

Either the date format is wrong, or an intermediate proxy rewrites the request header. Test against the pod directly (step 10) to isolate.

Body MD5 changes per request

Cloudflare is rewriting the body. Most common cause is Email Obfuscation touching a mailto: link. Wrap the link in <!--email_off-→ / <!--/email_off-→. Other candidates: Rocket Loader (strips ETag too), HTML Minify, or a Transform Rule.

Post-deployment breakage — users need hard reload

Last-Modified is not being updated by new deployments. Check that BuildProperties.getTime() actually advances between builds (some build reproducibility profiles zero this out deliberately; spring-boot-maven-plugin:build-info uses the current build instant by default and should not be overridden).

Cloudflare cf-cache-status: HIT on index.html

Cloudflare is caching the HTML despite CDN-Cache-Control: no-store. Check the Cloudflare Page Rules and Cache Rules for an override. The zone’s "Browser Cache TTL" setting must be "Respect Existing Headers".

10. References

  • registration-portal/src/main/java/…​/web/rest/IndexHtmlController.java

  • registration-portal/src/main/java/…​/web/filter/CacheControlFilter.java

  • registration-portal/src/main/java/…​/web/RequestClassifier.java

  • registration-portal/src/main/java/…​/config/WebConfigurer.java

  • registration-portal/src/main/java/…​/config/StaticResourcesWebConfiguration.java

  • Design journal: design-journal/2026-04/jhipster-spa-caching-definitive-guide.adoc

  • spring-boot#24099 — reproducible builds and classpath Last-Modified

  • spring-framework#25845 — disable Last-Modified handling on static resources

  • Cloudflare community — HTML ETag stripping

  • Cloudflare Cache Response Rules (March 2026)