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 |
|---|---|
|
Single source of truth for path classification. Exposes the |
|
Security-chain filter that sets |
|
Security-chain filter that forwards unmapped SPA routes ( |
|
REST controller with |
|
|
|
|
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:
-
The pod emits
ETag: W/"010f3e4c5fc64989bd5f3614dc181914f"correctly. -
ingress-nginx preserves the weak ETag.
-
Cloudflare’s response-normalisation pipeline strips
ETag,Content-Length, andAccept-Rangesfrom 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 explicitstrip_etags/strip_last_modifiedflags Cloudflare introduced in its Cache Response Rules (March 2026). -
Cloudflare does not strip
Last-Modifiedfrom 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:
-
The response body is different on every request — the per-request cipher means two consecutive downloads of
/index.htmlhave different MD5 hashes. -
Because the body is transformed, Cloudflare strips the upstream
ETagheader (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 |
2. Repeat visit (no deployment in between) |
Browser requests |
3. After a deployment (new build time) |
Browser requests |
4. SPA navigation (e.g. |
Browser requests |
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
IndexHtmlControllerfix -
An intermediate proxy is stripping
If-Modified-Sinceon 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 |
|---|---|
|
|
|
Something is stripping the header after origin. Check for a Cloudflare Transform Rule or Response Rule that touches |
|
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 |
Post-deployment breakage — users need hard reload |
|
Cloudflare |
Cloudflare is caching the HTML despite |
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-Modifiedhandling on static resources