Experience Edge is Sitecore's global CDN, but caching strategy makes the difference between millisecond response times and multi-second waits. Many implementations treat caching as an afterthought, resulting in stale content, cache invalidation issues, and poor Core Web Vitals.
This guide covers production-grade caching patterns that deliver sub-second responses while maintaining content freshness.
⚠️ The Caching Challenge
Common Problems:
- Stale Content: Users see outdated information after publishing
- Cache Invalidation: Correct invalidation is harder than it seems
- Unpredictable Performance: Sometimes fast, sometimes slow
- High Latency Spikes: Traffic causes cache misses
Real-World Impact:
- First Contentful Paint (FCP): 3-5 seconds (should be less than 1.8s)
- Largest Contentful Paint (LCP): 5-8 seconds (should be less than 2.5s)
- Bounce rate: 30-40% (should be less than 10%)
🏗️ Caching Layers Architecture
┌────────────────────────────────────────────────────┐
│ User Browser │
│ (Browser Cache: 1 hour) │
└────────────┬─────────────────────────────────────┘
│ Cache Miss
↓
┌────────────────────────────────────────────────────┐
│ CDN Edge (Akamai/Cloudflare) │
│ (CDN Cache: 5-60 minutes) │
└────────────┬─────────────────────────────────────┘
│ Cache Miss
↓
┌────────────────────────────────────────────────────┐
│ Experience Edge (Sitecore) │
│ (Origin Cache: 10 minutes) │
└────────────┬─────────────────────────────────────┘
│ Cache Miss
↓
┌────────────────────────────────────────────────────┐
│ Next.js Application │
│ (ISR Cache + Application Logic) │
└────────────┬─────────────────────────────────────┘
│ Cache Miss
↓
┌────────────────────────────────────────────────────┐
│ Sitecore XM Cloud (GraphQL) │
│ (Layout Service, Item Resolution) │
└────────────────────────────────────────────────────┘
🌐 Part 1: CDN Caching Configuration
// next.config.js
module.exports = {
headers: async () => {
return [
// Static pages: Long cache
{
source: '/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=604800, stale-while-revalidate=1209600'
// CDN: 7 days, Stale-while-revalidate: 14 days
}
]
},
// Dynamic content: Short cache with revalidation
{
source: '/news/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=300, stale-while-revalidate=600'
// CDN: 5 minutes, SWR: 10 minutes
},
{
key: 'Vary',
value: 'Accept-Encoding, User-Agent'
}
]
},
// Personalized content: No cache
{
source: '/account/:path*',
headers: [
{
key: 'Cache-Control',
value: 'private, no-cache, no-store, must-revalidate'
}
]
}
];
}
};
| Header | Purpose | Example |
|---|
s-maxage | CDN cache duration (seconds) | s-maxage=300 (5 min) |
stale-while-revalidate | Serve stale while refreshing | stale-while-revalidate=600 |
Vary | Cache key (vary by header) | Vary: Accept-Encoding |
ETag | Content version identifier | ETag: "abc123" |
Last-Modified | Last update timestamp | For conditional requests |
⚡ Part 2: Next.js ISR Configuration
ISR (Incremental Static Regeneration)
ISR combines static generation with dynamic updates.
// pages/blog/[slug].tsx
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<time>{post.publishedDate}</time>
</article>
);
}
export async function getStaticProps({ params }) {
const post = await getPostFromXmCloud(params.slug);
return {
props: { post },
revalidate: 60 // ISR: revalidate every 60 seconds
};
}
export async function getStaticPaths() {
// Generate paths for popular posts at build time
const paths = await getPopularPosts();
return {
paths: paths.map(post => ({ params: { slug: post.slug } })),
fallback: 'blocking' // Generate unknown paths on-demand
};
}
ISR Per Content Type
Different content types need different revalidation rates:
interface RevalidationConfig {
contentType: string;
revalidateSeconds: number;
reason: string;
}
const revalidationConfig: RevalidationConfig[] = [
{
contentType: 'News Article',
revalidateSeconds: 60,
reason: 'Frequent updates, time-sensitive'
},
{
contentType: 'Blog Post',
revalidateSeconds: 300,
reason: 'Updated occasionally'
},
{
contentType: 'Product Details',
revalidateSeconds: 1800,
reason: 'Infrequently changed'
},
{
contentType: 'Static Page',
revalidateSeconds: 86400,
reason: 'Annual updates'
}
];
function getRevalidateTime(template: string): number {
const config = revalidationConfig.find(c => c.contentType === template);
return config?.revalidateSeconds ?? 300;
}
On-Demand Revalidation
Trigger revalidation immediately after publishing:
// pages/api/revalidate.ts
export default async function handler(req, res) {
// Verify webhook is from Sitecore
const signature = req.headers['x-sitecore-signature'];
if (!verifySignature(req.body, signature)) {
return res.status(401).json({ message: 'Unauthorized' });
}
const { itemId, path } = req.body;
try {
// Revalidate specific paths
await res.revalidate(path);
// Also revalidate related pages
const relatedPaths = await getRelatedPages(itemId);
for (const relatedPath of relatedPaths) {
await res.revalidate(relatedPath);
}
return res.json({
revalidated: true,
paths: [path, ...relatedPaths]
});
} catch (error) {
console.error('Revalidation failed:', error);
return res.status(500).json({ message: 'Revalidation failed' });
}
}
// Sitecore webhook configuration
// POST /api/revalidate
// Trigger on: Publish Complete
// Include: Item path, template name
🔍 Part 3: GraphQL Query Caching
Query-Level Cache Configuration
// Apollo Client cache configuration
import { InMemoryCache, HttpLink } from '@apollo/client';
import { useMemo } from 'react';
const createApolloClient = () => {
return new ApolloClient({
ssrMode: typeof window === 'undefined',
link: new HttpLink({
uri: '/api/graphql',
credentials: 'same-origin',
headers: {
'sc-apikey': process.env.NEXT_PUBLIC_SITECORE_API_KEY
}
}),
cache: new InMemoryCache({
typePolicies: {
Item: {
keyFields: ['id'],
fields: {
// Cache this field for 10 minutes
children: {
merge(existing, incoming) {
return incoming;
},
read(existing) {
return existing;
}
}
}
},
Query: {
fields: {
// Cache search results
search: {
merge(existing = {}, incoming) {
return { ...existing, ...incoming };
}
}
}
}
}
})
});
};
Query Complexity Optimization
# INEFFICIENT: Over-fetching nested data
query GetPageWithAllData {
item(path: "/home") {
id
name
fields {
name
value
}
children {
id
name
children {
id
name
children { # Too deep!
id
name
}
}
}
}
}
# EFFICIENT: Fetch only needed data
query GetPageWithOptimizedFields {
item(path: "/home") {
id
name
title: field(name: "Title") {
value
}
description: field(name: "Description") {
value
}
}
}
# EFFICIENT: Separate queries for different concerns
fragment PageFields on Item {
id
name
title: field(name: "Title") { value }
}
fragment ChildrenSummary on Item {
id
name
url
}
query GetPage {
item(path: "/home") {
...PageFields
children(first: 10) {
...ChildrenSummary
}
}
}
🔄 Part 4: Cache Invalidation Patterns
Pattern 1: Time-Based Invalidation
// Simple: Set TTL and let cache expire
const config = {
news: { ttl: 5 * 60 }, // 5 minutes
blog: { ttl: 30 * 60 }, // 30 minutes
product: { ttl: 2 * 60 * 60 } // 2 hours
};
Pattern 2: Event-Based Invalidation
// Listen to XM Cloud publish events
app.post('/webhooks/item-published', async (req, res) => {
const { itemId, language, version } = req.body;
// 1. Get item details
const item = await xmCloud.getItem(itemId, language);
// 2. Determine affected URLs
const urls = await getUrlsForItem(item);
// 3. Invalidate caches
for (const url of urls) {
// Invalidate CDN
await cdn.purge(url);
// Trigger ISR
await fetch(`${process.env.SITE_URL}/api/revalidate`, {
method: 'POST',
body: JSON.stringify({ path: url, itemId })
});
// Invalidate Apollo cache
await invalidateApolloCache(itemId);
}
res.status(200).json({ invalidated: urls.length });
});
Pattern 3: Smart Cache Busting
// Include version hash in URLs
const getAssetUrl = (path: string, version: string) => {
return `${path}?v=${version}`;
};
// Sitecore item hash
const itemHash = createHash('sha256')
.update(JSON.stringify(item))
.digest('hex')
.substring(0, 8);
// URLs include hash
const urls = {
css: `/styles/main.css?v=${itemHash}`,
js: `/scripts/app.js?v=${itemHash}`,
json: `/api/item/${itemId}.json?v=${itemHash}`
};
// When item changes, hash changes, URLs change, browser fetches new version
🐛 Part 5: Debugging Stale Cache Issues
Identifying Cache Problems
// Middleware to detect cache issues
export async function middleware(req, res, next) {
const cacheKey = req.url;
const cachedAt = req.headers['x-cache-date'];
const now = Date.now();
// Check if content is stale
if (cachedAt && (now - parseInt(cachedAt)) > 300000) {
// More than 5 minutes old
console.warn(`Stale content served: ${cacheKey}`);
}
// Add diagnostic headers
res.setHeader('X-Cache-Date', now.toString());
res.setHeader('X-Cache-Status', 'HIT/MISS');
res.setHeader('X-Cache-Age', (now - parseInt(cachedAt || 0)).toString());
next();
}
// Log cache status in browser console
fetch('/api/page', {
headers: {
'Accept-Encoding': 'gzip',
'User-Agent': navigator.userAgent
}
}).then(response => {
console.log('Cache Headers:', {
cacheControl: response.headers.get('cache-control'),
etag: response.headers.get('etag'),
lastModified: response.headers.get('last-modified'),
age: response.headers.get('age'), // CDN age
via: response.headers.get('via') // CDN provider
});
});
Troubleshooting Matrix
| Symptom | Cause | Solution |
|---|
| Content always fresh but slow | Cache disabled | Check Cache-Control headers |
| Stale content shown | TTL too long | Reduce s-maxage, set revalidate |
| "Always different" responses | Cache key issues | Check Vary headers |
| High latency spikes | Cache miss storms | Enable stale-while-revalidate |
| Users see different content | Vary header missing | Add Vary: Accept-Encoding |
🎯 Conclusion
Sub-second response times require coordinated caching across multiple layers. Start with ISR and CDN cache headers, then add sophisticated invalidation as needed. Monitor continuously and adjust TTLs based on real user data.