Skip to content

09 — Cache and Performance

PRD Document · Savoy Signature Hotels — Multi-Site Headless Platform
Version: 1.0 · Date: 2026-03-04
Related docs: 01_General_Architecture.md, 02_Infrastructure_and_Environments.md, 08_API_Contracts.md


This document defines the caching strategy and performance optimization approach for the Savoy Signature platform. It covers the multi-layer cache architecture, Cloudflare configuration, the relationship between edge cache and Next.js use cache, purge mechanics, image optimization, and Core Web Vitals targets.


Layer 4 — Umbraco

Layer 3 — Next.js App

Layer 2 — Cloudflare Edge

Layer 1 — Browser

Browser Cache

Edge Cache — HTML + Static Assets

use cache (selective)

Output Cache + DB Cache

LayerWhat’s CachedTTLInvalidation
BrowserStatic assets (JS, CSS, fonts, images)1 year (immutable)Hash-based filenames (Turbopack)
Cloudflare EdgeSSR HTML pagesInfinite (s-maxage=31536000)Programmatic purge via API
Next.js use cacheExpensive computations, shared data (nav structure)Short (5–15 min)Time-based expiry; not primary cache
UmbracoAPI responses, DB queriesInternal (managed by Umbraco)Automatic on content publish

[!IMPORTANT] Cloudflare is the primary cache layer. Next.js use cache is NOT used for page content. All content fetches use cache: 'no-store'. Cloudflare is the single source of truth for cached pages.


Content TypeCache-Control HeaderCloudflare Behavior
SSR HTMLpublic, max-age=0, s-maxage=31536000, stale-while-revalidate=60Edge cache 1 year (purged via API); browser never caches stale HTML; stale-while-revalidate serves stale during refresh
Static assets (JS, CSS)public, max-age=31536000, immutableEdge + browser cache forever; hashed filenames
Images (CDN)public, max-age=259200030-day cache at edge + browser
API responsesprivate, no-storeNever cached at edge
Fontspublic, max-age=31536000, immutableEdge + browser cache forever
apps/web/next.config.ts
const nextConfig: NextConfig = {
async headers() {
return [
{
// SSR pages: cache at edge only (browser must revalidate)
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=0, s-maxage=31536000, stale-while-revalidate=60',
},
{
key: 'CDN-Cache-Control',
value: 'max-age=31536000', // Cloudflare-specific edge TTL
},
],
},
{
// API routes: never cache
source: '/api/:path*',
headers: [
{
key: 'Cache-Control',
value: 'private, no-store',
},
],
},
];
},
};
RuleMatchSetting
HTML Cache*savoysignature.com/*Cache Level: Cache Everything
API Bypass*/api/*Cache Level: Bypass
Umbraco Backoffice*umbraco*Cache Level: Bypass
Storybook (internal)*.wycreative.com/*Cache Level: Standard

ScenarioTriggerPurge TypeScope
Content PublishUmbraco webhookSelective URL purgeAffected pages + dependents
Code DeployCI/CD pipelineSelective by cache-tag or Purge AllPages using changed templates/modules
EmergencyManual (Umbraco dashboard)Purge AllEntire zone

[!NOTE] Cloudflare Pro supports cache-tag purge — this feature is available on all plans (Free, Pro, Business, Enterprise) since 2024. Tags are set via the Cache-Tag response header from the origin.

The Next.js application adds Cache-Tag headers to every SSR response, identifying which modules/templates were used to render the page:

apps/web/src/helpers/cache-tags.ts
// Example: set cache tags on response
export function buildCacheTags(siteKey: string, modules: string[]): string {
// Tags identify: site, page template, every module used
return [
`site:${siteKey}`,
...modules.map(m => `module:${m}`),
].join(',');
}
// In next.config.ts headers or via response:
// Cache-Tag: site:savoy-palace,module:heroSlider,module:cardGrid,module:bookingBar

During a code deploy, the CI/CD pipeline determines which modules changed and calls the Cloudflare API to purge by tag:

Terminal window
# Example: purge all pages that use the heroSlider module
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"tags":["module:heroSlider"]}'

If the change affects a layout-level component (e.g., header, footer), the scope is too broad and the pipeline falls back to Purge All.

When a content item is published, the webhook must purge not just the item’s URL but also pages that reference it:

Content Published: Room 'Deluxe Ocean'

Purge: /pt/accommodations/rooms/deluxe-ocean

Purge: /en/accommodations/rooms/deluxe-ocean

Purge: /pt/accommodations (rooms list)

Purge: /en/accommodations

Purge: /pt/ (if featured on homepage)

Purge: /en/

Cloudflare Pro PlanLimit
Purge by URLUp to 30 URLs per API call
Purge Everything1 call per zone
Rate limit1,000 purge API calls per 24 hours

For content publishes that affect more than 30 URLs, batch the purge calls or fall back to “Purge Everything.”

After purging, the webhook handler sends warmup requests to re-populate the edge cache:

async function warmup(urls: string[]) {
// Limit concurrency to avoid overwhelming origin
const CONCURRENCY = 5;
const batches = chunk(urls, CONCURRENCY);
for (const batch of batches) {
await Promise.allSettled(
batch.map(url =>
fetch(url, {
headers: { 'X-Warmup': 'true' },
cache: 'no-store',
})
)
);
// Small delay between batches
await sleep(200);
}
}

✅ Use use cache For❌ Do NOT Cache
Navigation tree computation (expensive to build from flat API)Page content (Cloudflare handles this)
Site configuration lookupUser-specific data (no user auth, but future-proof)
Shared labels / dictionary itemsAPI responses from Umbraco
Third-party API responses (if applicable)Any content that changes via CMS
'use cache';
import { cacheLife } from 'next/cache';
export async function getNavigationTree(siteKey: string, locale: string) {
cacheLife('minutes'); // Cache for ~5 minutes
const client = new UmbracoClient();
const nav = await client.getNavigation(siteKey, locale);
return buildNavigationTree(nav?.items || []);
}

ConcernApproach
FormatServe WebP/AVIF via next/image automatic optimization
ResponsiveDesktop + Mobile images (mandatory per responsiveImageComposition) served via <picture>
Sizingnext/image with sizes prop for responsive srcset
Lazy loadingDefault: lazy. First visible image (LCP candidate): priority={true}
CDNImages served via Cloudflare CDN (Blob Storage → Cloudflare)
Max file sizeDesktop: ≤500KB, Mobile: ≤200KB (compressed)
ContextDesktop SizeMobile SizeFormat
Hero / Full-width1920 × 1080750 × 1000WebP
Card thumbnail600 × 400375 × 250WebP
Gallery1440 × 960750 × 500WebP
LogoSVGSVGSVG
IconsSVGSVGSVG
FeatureSetting
PolishLossy (aggressive compression)
WebPEnabled (auto-serve WebP to supported browsers)
MirageEnabled (lazy-load images, responsive image placeholders)

MetricTargetWhat It Measures
LCP (Largest Contentful Paint)< 2.5sTime until largest visible element renders
FID (First Input Delay)< 100msTime from user interaction to browser response
CLS (Cumulative Layout Shift)< 0.1Visual stability (no layout jumps)
INP (Interaction to Next Paint)< 200msResponsiveness of interactions
TTFB (Time to First Byte)< 800msServer response time
FCP (First Contentful Paint)< 1.8sTime until first content painted
OptimizationImplementationImpact
Edge cache (Cloudflare)TTFB < 50ms for cached pages (global PoPs)TTFB, LCP
Server ComponentsZero client-side JS for static content modulesFID, INP, bundle
Turbopack2–5× faster builds, 10× faster HMRDX (developer experience)
next/fontZero FOUT, preloaded, subset per themeCLS, LCP
next/imageWebP/AVIF, srcset, lazy-load, priority for LCPLCP, CLS
Desktop + Mobile imagesCorrect image size per viewportLCP, bandwidth
BEM + SASSNo runtime CSS-in-JS, structured stylesFID, bundle
Dynamic importsLazy-load heavy modules (gallery lightbox, map)Bundle, FID
Prefetch<Link prefetch> for likely navigation targetsLCP (subsequent)
No layout shiftsExplicit width/height on images, reserved space for adsCLS
GTM asyncnext/script with afterInteractive strategyFID, LCP
ToolPurposeCadence
Google PageSpeed InsightsLab + field data per URLWeekly + per-deploy
Chrome UX Report (CrUX)Real-user field dataMonthly review
Lighthouse CIAutomated lab testing in CI pipelineEvery PR
openClaw AI QAPerformance audit as part of AI QA gateEvery PR
Cloudflare AnalyticsEdge cache hit ratio, latencyContinuous (dashboard)
Azure Application InsightsServer-side performance, error ratesContinuous (alerts)

ResourceBudget
Total page weight (first load, gzipped)< 500KB
JavaScript bundle (per page, gzipped)< 200KB
CSS (all themes, gzipped)< 50KB
LCP image< 500KB (desktop), < 200KB (mobile)
Third-party scripts (GTM, Navarino)< 100KB
Fonts (per site/theme)< 100KB

  • Edge cache hit ratio > 95% after warmup
  • TTFB < 50ms for cached pages (Cloudflare edge)
  • TTFB < 800ms for uncached pages (origin)
  • LCP < 2.5s on mobile (3G Fast connection)
  • CLS < 0.1 on all pages
  • INP < 200ms on all interactive pages
  • All images served in WebP/AVIF format
  • Desktop + Mobile responsive images load correct variant per viewport
  • Lighthouse Performance score ≥ 90 on all page templates
  • Cache purge completes within 5 seconds of content publish
  • Warmup re-caches purged pages within 30 seconds
  • Total page weight < 500KB on all pages

Next document: 10_MultiLanguage_and_i18n.md