Performance & Caching
How Caching Works End-to-End
Section titled “How Caching Works End-to-End”Sources:
- RFC: omni-web-gateway CloudFront Routing Gateway — defines the cache strategy during SFCC→MFE migration (Decision 1: origin-controlled TTL,
X-Origin-Versionin cache key, cache poisoning prevention)- ADR-0013: Use CloudFront Functions for Gateway Router — decides on cache policy configuration (1-day default TTL respecting origin
Cache-Control, cookies in cache key, Gzip+Brotli)omni-web-gateway/lib/common/cloudfront/shared-policies.ts— implements the cache policy in CDKomni-web-content-frontend/src/next.config.ts— MFE-side configuration (ISR, headers, images)omni-web-content-frontend/src/repositories/sanity/— Sanity CDN usage andunstable_cachepattern
Every request flows through multiple cache layers before reaching the application. Understanding this stack is critical — it determines latency, cost, and content freshness for end users.
Key facts:
- CloudFront cache TTL is origin-controlled (default 0s, max 365 days) — the MFE sets
Cache-Controlheaders - ISR pages revalidate every 5 minutes — a stale response is served while a fresh one is generated in the background
- The
No-Vary-Searchheader prevents marketing parameters from fragmenting the cache - Sanity CDN invalidates instantly on publish, but ISR adds a 5-minute delay before the MFE picks up changes
Cache Layers at a Glance
Section titled “Cache Layers at a Glance”| Layer | What it Caches | TTL | Invalidation |
|---|---|---|---|
| CloudFront | All responses (origin-controlled) | 0s default, up to 365d | Origin Cache-Control header |
| Next.js ISR | Content pages ([...slugs]) | 300s (5 min) | Time-based revalidation |
unstable_cache | Microcopy translations | 300s (5 min) | Tag-based (microcopy-<locale>) |
| Sanity CDN | GROQ query results | Automatic | Instant on content publish |
| Static Params | Home page locale routes | Build time | Redeploy |
CloudFront Cache Policy
Section titled “CloudFront Cache Policy”The gateway uses an origin-controlled cache policy — meaning the CDN respects whatever Cache-Control header the origin (Next.js) sends:
// From omni-web-gateway/lib/common/cloudfront/shared-policies.tsnew CachePolicy(this, 'OriginControlledCachePolicy', { defaultTtl: Duration.seconds(0), // Don't cache unless origin says to minTtl: Duration.seconds(0), maxTtl: Duration.days(365), // Respect long TTLs for static assets cookieBehavior: CacheCookieBehavior.none(), queryStringBehavior: CacheQueryStringBehavior.all(), enableAcceptEncodingGzip: true, enableAcceptEncodingBrotli: true,});What this means for MFE developers:
- If you don’t set
Cache-Controlheaders, CloudFront won’t cache your responses - Static assets (
_next/static/*) get long TTLs automatically from Next.js - ISR pages get
s-maxage=300, stale-while-revalidatefrom the ISR mechanism - API routes default to no caching unless you explicitly set headers
ISR (Incremental Static Regeneration)
Section titled “ISR (Incremental Static Regeneration)”Content pages use ISR to balance freshness with performance. A page is served from cache and revalidated in the background after the TTL expires:
export const revalidate = 300; // 5 minutesSitemaps use a longer revalidation window since they change infrequently:
export const revalidate = 3600; // 1 hourStatic Generation at Build Time
Section titled “Static Generation at Build Time”The home page pre-generates routes for all supported locales at build time — these are instantly available without a cold-start penalty:
export function generateStaticParams() { return routing.locales.map(locale => ({ locale }));}The unstable_cache Pattern
Section titled “The unstable_cache Pattern”For data that is shared across many pages (microcopy/translations), use unstable_cache with tag-based invalidation:
export const fetchMicrocopy = async (locale: string) => { return unstable_cache( async () => { const [lang, country] = locale.split('-'); const data = await baseClient.fetch(MICROCOPY_QUERY, { lang, country: country.toUpperCase(), }); return transformEntriesToNested(data.entries, lang); }, [`microcopy-${locale}`], // Cache key { revalidate: 300, tags: [`microcopy-${locale}`] }, // 5 min + tag )();};When to use this pattern:
- Data shared across many pages (translations, site config, navigation)
- Data that changes infrequently (every few hours, not every request)
- Data that benefits from a deterministic cache key
No-Vary-Search: Boosting Cache Hit Rates
Section titled “No-Vary-Search: Boosting Cache Hit Rates”Marketing parameters (utm_source, gclid, fbclid, etc.) are appended to URLs by advertising platforms. Without intervention, each parameter combination creates a separate cache entry — destroying hit rates.
The No-Vary-Search header tells CloudFront these parameters don’t affect content:
// src/next.config.ts — headers() function{ key: 'No-Vary-Search', value: 'params=("utm_campaign" "utm_content" "utm_medium" ' + '"utm_source" "utm_id" "utm_term" "brid" "fbclid" ' + '"gclid" "wbraid" "gbraid" "gclsrc" "gad_campaign" ' + '"gad_campaignid" "gad_source" "msclkid" "epik" "pp")',}Impact: /page?utm_source=google and /page?utm_source=facebook now serve the same cached response — a single cache entry instead of thousands.
Image Optimization
Section titled “Image Optimization”Sanity CDN for All Content Images
Section titled “Sanity CDN for All Content Images”All content images are served from cdn.sanity.io with on-the-fly transformations (resize, crop, format). No images are self-hosted:
images: { path: imagesPath, deviceSizes: [380, 450, 640, 767, 840, 1024, 1240, 1335], remotePatterns: [ { protocol: 'https', hostname: 'cdn.sanity.io', pathname: '/**' }, { protocol: 'https', hostname: 'acc.hema.nl' }, { protocol: 'https', hostname: 'acc.hema.com' }, { protocol: 'https', hostname: 'staging.hema.nl' }, { protocol: 'https', hostname: 'staging.hema.com' }, { protocol: 'https', hostname: 'www.hema.nl' }, { protocol: 'https', hostname: 'www.hema.com' }, ],}The deviceSizes are tuned to HEMA’s design breakpoints, not Next.js defaults (640, 750, 828, 1080, 1200, 1920, 2048, 3840).
Universal Image Loader
Section titled “Universal Image Loader”The universalImageLoader handles all image sources in a multi-zone environment:
Key behavior: in multi-zone mode, the image optimizer path is prefixed with the zone (/_zones/{serviceId}/_next/image) so CloudFront routes the request to the correct MFE.
Zone-Based Asset Routing
Section titled “Zone-Based Asset Routing”Each MFE in the multi-zone setup has its own asset prefix. This ensures CloudFront caches static assets per-zone:
export function deriveZoneConfig(serviceId: string | undefined | null) { const zonePrefix = serviceId ? `/_zones/${serviceId}` : ''; const imagesPath = zonePrefix ? `${zonePrefix}/_next/image` : undefined;
const rewrites = zonePrefix ? async () => ({ beforeFiles: [ { source: `${zonePrefix}/_next/image`, destination: '/_next/image' }, ], afterFiles: [ { source: '/images/:path*', destination: `${zonePrefix}/images/:path*` }, ], fallback: [], }) : undefined;
return { assetPrefix: zonePrefix, rewrites, imagesPath };}Standalone Output & Cold Starts
Section titled “Standalone Output & Cold Starts”The standalone build mode creates a minimal production bundle for fast container startup:
output: 'standalone',What it does:
- Traces dependencies and copies only required
node_modulesfiles - Final image:
node server.js+.next/static+public/(no dev deps, no source) - Multi-stage Docker build separates build from runtime
# Runtime stage — minimal imageFROM node:22-slim AS runnerCOPY --from=builder /app/.next/standalone ./COPY --from=builder /app/public ./publicCOPY --from=builder /app/.next/static ./.next/staticRUN mkdir -p .next/cacheCMD ["node", "server.js"]Performance Headers
Section titled “Performance Headers”All pages include these security + performance headers:
| Header | Value | Purpose |
|---|---|---|
X-DNS-Prefetch-Control | on | Pre-resolve DNS for linked domains |
Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | Force HTTPS |
X-Content-Type-Options | nosniff | Prevent MIME sniffing |
Referrer-Policy | origin-when-cross-origin | Control referrer leakage |
No-Vary-Search | (marketing params) | CDN cache efficiency |
The layout also includes a preconnect hint for the Google Tag Manager domain:
<link rel="preconnect" href="https://htmsa.hema.nl" />Font Loading
Section titled “Font Loading”The HEMA brand font (hurmeHema) is loaded via @hema/hds-assets/next/font which uses Next.js’s built-in font optimization:
- Self-hosts the font files (no external requests)
- Applies
font-display: swapto prevent blocking render - Generates optimized
@font-facedeclarations at build time
Sanity Client Performance
Section titled “Sanity Client Performance”The Sanity client is configured for maximum CDN usage in production:
export const baseClient = createClient({ projectId, dataset, apiVersion, perspective: 'published', useCdn: true, // ← Serve from Sanity's global CDN token, requestTagPrefix: createRequestTag(requestTagPrefix),});Request tags (requestTagPrefix) allow monitoring query performance in Sanity’s dashboard — you can see which queries are slow, frequently called, or returning large payloads.
Performance Checklist for New MFEs
Section titled “Performance Checklist for New MFEs”| Item | Why |
|---|---|
Set output: 'standalone' | Smaller image, faster cold starts |
Set revalidate: 300 on content pages | Balance freshness with CDN caching |
Use unstable_cache for shared data | Avoid re-fetching translations per request |
Configure deviceSizes to match breakpoints | Avoid generating unnecessary image variants |
Add No-Vary-Search for marketing params | Prevent cache fragmentation from UTMs |
Use useCdn: true for Sanity client | Serve published content from edge |
Use generateStaticParams for known routes | Pre-build locale/category pages |
Serve images from cdn.sanity.io | On-the-fly transforms, no self-hosting |
| Use zone-based asset prefix | Correct routing in multi-zone |
Add requestTagPrefix to Sanity client | Monitor GROQ query performance |
| Add preconnect hints for external origins | Reduce DNS/TLS latency |