SPA Cache & CDN Strategy
1. Overview
A portal serves three classes of response, each with a distinct caching rule:
| Class | Browser | CDN (Cloudflare) |
|---|---|---|
Fingerprinted static assets ( |
|
Honour the browser policy; |
|
|
|
API, |
|
Not cacheable by any layer |
Getting this wrong causes the classic JHipster-SPA deploy failure: the CDN serves an old index.html referencing hashed bundle filenames that the new deployment no longer has, resulting in a "white screen" or "chunk load error" until the CDN is manually purged. The strategy below is engineered specifically against that failure mode.
This page documents the working strategy; see design-journal/2026-04/jhipster-spa-caching-definitive-guide.adoc for the full analysis of the failure modes and the path from discovery to fix.
See also: Portal Pattern.
2. Implementation
Two collaborating components:
-
StaticResourcesWebConfiguration— SpringWebMvcConfigurerthat registers resource handlers for fingerprinted assets withCacheControl.maxAge(TTL).cachePublic().immutable(). -
CacheControlFilter—OncePerRequestFilterthat setsCache-Control(andCDN-Cache-Controlwhere relevant) on everything not handled by the resource handler.
The split matters: the resource handler sets Cache-Control before Spring serves the file, so the filter must not overwrite it for static assets. The filter’s logic explicitly skips STATIC_ASSET and STATIC_CONTENT categories for that reason.
2.1. Path classification
A single RequestClassifier produces the category that both the filter, the tenant-resolution filter, and the static-resource configuration use:
public enum Category {
SPA_ENTRY, // /index.html (exact)
SPA_ROUTE, // /, /events, /members/42 — no extension, no known prefix
API, // /api/**
SERVICE_PROXY, // /services/**
AUTH, // /login/**, /oauth2/**
INFRASTRUCTURE, // /management, /v3/, /swagger-ui, /actuator, /livez, /readyz
STATIC_CONTENT, // /content/**, /i18n/**, /app/**
STATIC_ASSET, // any path containing a dot (e.g. main.abc123.js)
}
Order matters: more specific prefixes are checked before the generic "has a dot" fallback. /api/v2.1/endpoint classifies as API, not STATIC_ASSET. Reference: [registration-portal/…/web/RequestClassifier.java](registration-portal/src/main/java/za/co/idealogic/registration/ui/web/RequestClassifier.java).
2.2. CacheControlFilter
switch (category) {
case API, SERVICE_PROXY, AUTH, INFRASTRUCTURE ->
response.setHeader("Cache-Control", "no-store");
case SPA_ENTRY, SPA_ROUTE -> {
response.setHeader("Cache-Control", "no-cache");
response.setHeader("CDN-Cache-Control", "no-store");
}
default -> {
// STATIC_ASSET and STATIC_CONTENT: StaticResourcesWebConfiguration already set headers
}
}
Headers are set before chain.doFilter() so they are present even if the chain commits the response during SPA forwarding. Reference: [registration-portal/…/web/filter/CacheControlFilter.java](registration-portal/src/main/java/za/co/idealogic/registration/ui/web/filter/CacheControlFilter.java).
2.3. StaticResourcesWebConfiguration
Registers resource handlers for /*.js, /\*.css, /*.svg, /\*.png, /\*.woff2, etc., with long max-age + public + immutable. Active only under the prod profile; dev serves from webpack-dev-server which has its own no-cache default.
2.4. Why CDN-Cache-Control distinct from Cache-Control
CDN-Cache-Control is Cloudflare’s (and standard draft-cache-control-10) way to tell the edge cache one thing and the browser another. For index.html we want:
-
Browser:
no-cache— revalidate on every hard-navigate, honour ETag for 304s, but keep in cache for soft reloads. -
CDN:
no-store— never cache at the edge. Every browser revalidation must reach the origin and get the currentindex.htmlreferencing the current hashed bundle names.
Cache-Control: no-cache alone is not enough — some CDN configurations treat it as "cache but revalidate", which is the wrong behaviour for a deploy-sensitive file.
3. ETag for SPA-Entry Revalidation
index.html is served with Cache-Control: no-cache so every browser request revalidates. Revalidation is cheap if we set an ETag — the browser sends If-None-Match, origin returns 304 Not Modified with no body, browser reuses its cached copy. When the deploy changes index.html (e.g. hashed bundle names update), the ETag changes, the origin returns 200 OK with the new body, and the SPA reloads with correct references.
Spring Boot’s ShallowEtagHeaderFilter handles this automatically for static resources. Register it for SPA-entry paths explicitly.
4. Cloudflare-Specific Notes
-
Cache Rules (Cloudflare’s newer ruleset) should honour
CDN-Cache-Control. Verify by inspecting response headers at the edge (cf-cache-status) and on the origin. -
Page Rules (legacy) may override response headers; audit before relying on headers.
-
Compression — Cloudflare will gzip responses itself if the origin did not. If the origin gzips JS incorrectly (JHipster default had a broken gzip that swapped content-types), the symptom is
cf-cache-status: BYPASS+ content-type mismatches. Seedesign-journal/2026-04/jhipster-spa-caching-definitive-guide.adoc§ JS gzip compression for the fix. -
Vary — always include
Vary: Accept-Encodingon compressible responses. MissingVary+ Cloudflare = cached compressed body served to clients that can’t decompress.
5. Testing
Minimum smoke tests per deploy:
-
curl -I https://<portal>/— expectCache-Control: no-cache,CDN-Cache-Control: no-store, anETag. -
curl -I https://<portal>/main.<hash>.js— expectCache-Control: public, max-age=31536000, immutable. -
curl -I https://<portal>/services/admin-service/api/organisations— expectCache-Control: no-store, proxied response. -
curl -I -H "If-None-Match: <etag>" https://<portal>/— expect304 Not Modified. -
Deploy new SPA build → hard reload in browser → expect new bundle names immediately, no 404s on stale chunk files.
Automate (1)–(4) as a post-deploy smoke check; automate (5) as part of staging promotion gates.
6. Deviations from Stock JHipster
Stock JHipster 8 has much of the classification and resource handler setup but:
-
No
CDN-Cache-Controlheader — we added this. -
No
ShallowEtagHeaderFilterregistration for SPA paths — we registered it. -
Cache-Controlon/api/**is missing entirely in some generated variants — we setno-store. -
JS gzip compression had a content-type mismatch bug — we fixed it.
These deltas live in CacheControlFilter, StaticResourcesWebConfiguration, and RequestClassifier. Keep them in any new portal; revisit when JHipster’s generator catches up.
7. Reference Implementation
| File | Role |
|---|---|
|
Per-path-class header setting |
|
Single source of truth for path categories |
|
Resource handler with |
|
Filter chain registration + |