Skip to content

Performance & Caching

Sources:

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.

Diagram

Key facts:

  • CloudFront cache TTL is origin-controlled (default 0s, max 365 days) — the MFE sets Cache-Control headers
  • ISR pages revalidate every 5 minutes — a stale response is served while a fresh one is generated in the background
  • The No-Vary-Search header 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
LayerWhat it CachesTTLInvalidation
CloudFrontAll responses (origin-controlled)0s default, up to 365dOrigin Cache-Control header
Next.js ISRContent pages ([...slugs])300s (5 min)Time-based revalidation
unstable_cacheMicrocopy translations300s (5 min)Tag-based (microcopy-<locale>)
Sanity CDNGROQ query resultsAutomaticInstant on content publish
Static ParamsHome page locale routesBuild timeRedeploy

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.ts
new 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-Control headers, 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-revalidate from the ISR mechanism
  • API routes default to no caching unless you explicitly set headers

Content pages use ISR to balance freshness with performance. A page is served from cache and revalidated in the background after the TTL expires:

src/app/[locale]/[...slugs]/page.tsx
export const revalidate = 300; // 5 minutes

Sitemaps use a longer revalidation window since they change infrequently:

src/app/sitemap-index/route.ts
export const revalidate = 3600; // 1 hour

The home page pre-generates routes for all supported locales at build time — these are instantly available without a cold-start penalty:

src/app/[locale]/page.tsx
export function generateStaticParams() {
return routing.locales.map(locale => ({ locale }));
}

For data that is shared across many pages (microcopy/translations), use unstable_cache with tag-based invalidation:

src/repositories/sanity/microcopy-repository.ts
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

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.

All content images are served from cdn.sanity.io with on-the-fly transformations (resize, crop, format). No images are self-hosted:

src/next.config.ts
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).

The universalImageLoader handles all image sources in a multi-zone environment:

Diagram

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.

Each MFE in the multi-zone setup has its own asset prefix. This ensures CloudFront caches static assets per-zone:

src/utils/zone-config.ts
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 };
}

The standalone build mode creates a minimal production bundle for fast container startup:

src/next.config.ts
output: 'standalone',

What it does:

  • Traces dependencies and copies only required node_modules files
  • Final image: node server.js + .next/static + public/ (no dev deps, no source)
  • Multi-stage Docker build separates build from runtime
# Runtime stage — minimal image
FROM node:22-slim AS runner
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
RUN mkdir -p .next/cache
CMD ["node", "server.js"]

All pages include these security + performance headers:

HeaderValuePurpose
X-DNS-Prefetch-ControlonPre-resolve DNS for linked domains
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadForce HTTPS
X-Content-Type-OptionsnosniffPrevent MIME sniffing
Referrer-Policyorigin-when-cross-originControl 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" />

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: swap to prevent blocking render
  • Generates optimized @font-face declarations at build time

The Sanity client is configured for maximum CDN usage in production:

src/repositories/sanity/client.ts
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.

ItemWhy
Set output: 'standalone'Smaller image, faster cold starts
Set revalidate: 300 on content pagesBalance freshness with CDN caching
Use unstable_cache for shared dataAvoid re-fetching translations per request
Configure deviceSizes to match breakpointsAvoid generating unnecessary image variants
Add No-Vary-Search for marketing paramsPrevent cache fragmentation from UTMs
Use useCdn: true for Sanity clientServe published content from edge
Use generateStaticParams for known routesPre-build locale/category pages
Serve images from cdn.sanity.ioOn-the-fly transforms, no self-hosting
Use zone-based asset prefixCorrect routing in multi-zone
Add requestTagPrefix to Sanity clientMonitor GROQ query performance
Add preconnect hints for external originsReduce DNS/TLS latency