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 (main.abc123.js, styles.def456.css, fonts, images with hashed filenames)

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

Honour the browser policy; CDN-Cache-Control may be the same or longer

index.html and SPA route paths (/, /events, /members/42/edit)

Cache-Control: no-cache — browser must revalidate via ETag

CDN-Cache-Control: no-store — CDN must never cache

API, /services/, /auth/, /management/, /v3/

Cache-Control: no-store

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:

  1. StaticResourcesWebConfiguration — Spring WebMvcConfigurer that registers resource handlers for fingerprinted assets with CacheControl.maxAge(TTL).cachePublic().immutable().

  2. CacheControlFilterOncePerRequestFilter that sets Cache-Control (and CDN-Cache-Control where 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 current index.html referencing 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. See design-journal/2026-04/jhipster-spa-caching-definitive-guide.adoc § JS gzip compression for the fix.

  • Vary — always include Vary: Accept-Encoding on compressible responses. Missing Vary + Cloudflare = cached compressed body served to clients that can’t decompress.

5. Testing

Minimum smoke tests per deploy:

  1. curl -I https://<portal>/ — expect Cache-Control: no-cache, CDN-Cache-Control: no-store, an ETag.

  2. curl -I https://<portal>/main.<hash>.js — expect Cache-Control: public, max-age=31536000, immutable.

  3. curl -I https://<portal>/services/admin-service/api/organisations — expect Cache-Control: no-store, proxied response.

  4. curl -I -H "If-None-Match: <etag>" https://<portal>/ — expect 304 Not Modified.

  5. 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-Control header — we added this.

  • No ShallowEtagHeaderFilter registration for SPA paths — we registered it.

  • Cache-Control on /api/** is missing entirely in some generated variants — we set no-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

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

Per-path-class header setting

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

Single source of truth for path categories

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

Resource handler with immutable policy for hashed assets (prod only)

registration-portal/src/main/java/…​/config/SecurityConfig.java (or equivalent)

Filter chain registration + ShallowEtagHeaderFilter wiring

  • Portal Pattern

  • design-journal/2026-04/jhipster-spa-caching-definitive-guide.adoc — full analysis + fix plan

  • design-journal/2026-03/spa-caching-cdn-strategy.adoc — earlier iteration of this thread

9. Change History

Date Change

2026-04-24

Initial draft. Grounded in registration-portal CacheControlFilter/RequestClassifier and the 2026-04 JHipster SPA caching design-journal thread.