Caching best practices
Learn when to use caching, when not to cache, and understand the consequences of caching decisions including cross-region cache purging with Cloudflare.
Caching improves performance and reduces load on Optimizely Graph, but incorrect caching strategies can serve stale content or create inconsistencies. This guide explains when to cache, when not to cache, and how to manage cache invalidation effectively.
When to use caching
Static or rarely changing content
Cache content that changes infrequently:
// Marketing pages, documentation, about pages
export async function getStaticProps() {
const data = await fetchFromGraph(MARKETING_QUERY);
return {
props: { data },
revalidate: 3600 // Regenerate every hour
};
}Best for:
- Marketing pages
- Documentation
- Legal pages (terms, privacy)
- Author biographies
- Category pages
- Product catalogs with stable inventory
Cache duration: 1 hour to 24 hours (CDN + application cache)
Content with predictable update patterns
Cache content that updates on a schedule:
// News articles published daily at 6 AM
res.setHeader('Cache-Control', 'public, s-maxage=21600'); // 6 hoursBest for:
- Scheduled blog posts
- Daily/weekly newsletters
- Recurring reports
- Event calendars
Cache duration: Until next expected update
High-traffic, low-change content
Cache popular content to reduce server load:
// Homepage - high traffic, updated manually
const { data } = useQuery(GET_HOMEPAGE, {
fetchPolicy: 'cache-first',
nextFetchPolicy: 'cache-and-network'
});Best for:
- Homepage
- Popular blog posts
- Featured products
- Navigation menus
Cache duration: 5-15 minutes with stale-while-revalidate
When NOT to use caching
Personalized content
Do not cache user-specific content:
// ❌ Bad: Caching personalized content
const { data } = useQuery(GET_USER_RECOMMENDATIONS, {
variables: { userId },
fetchPolicy: 'cache-first' // Wrong - will show same data for all users
});
// ✅ Good: No caching for personalized content
const { data } = useQuery(GET_USER_RECOMMENDATIONS, {
variables: { userId },
fetchPolicy: 'network-only'
});Never cache:
- User dashboards
- Personalized recommendations
- Shopping carts
- User-specific search results
- Authentication-dependent content
Real-time or frequently changing data
Avoid caching content that must always be current:
// Stock prices, inventory, live scores
const { data } = useQuery(GET_INVENTORY, {
variables: { productId },
fetchPolicy: 'network-only',
pollInterval: 5000 // Refresh every 5 seconds
});Never cache:
- Inventory levels
- Pricing (if dynamic)
- Live event scores
- Real-time analytics
- Active bidding/auction data
Content with frequent unpredictable updates
Don't cache if you can't predict when content changes:
// Breaking news, emergency alerts
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');Avoid caching:
- Breaking news
- Emergency notifications
- Flash sales
- Limited-time offers
- User-generated content (comments, reviews)
Caching layers
1. CDN caching (Cloudflare)
Cache at the edge for global performance:
// pages/api/content.js
export default async function handler(req, res) {
const data = await fetchFromGraph(query);
// CDN caching with Cloudflare
res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=3600');
res.setHeader('CDN-Cache-Control', 'max-age=300');
res.json(data);
}Cache-Control directives:
| Directive | Description | When to use |
|---|---|---|
public | Can be cached by any cache | Public content |
private | Only browser cache, not CDN | User-specific content |
s-maxage=N | CDN cache duration (seconds) | All public content |
max-age=N | Browser cache duration | Static assets |
stale-while-revalidate=N | Serve stale while fetching fresh | High-traffic pages |
no-cache | Revalidate before serving | Frequently updated |
no-store | Never cache | Sensitive data |
2. Application caching
Cache query results in your application:
import { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
Article: {
keyArgs: ['where', 'orderBy'],
merge(existing, incoming, { args }) {
// Handle pagination
if (!args?.skip) return incoming;
return {
...incoming,
items: [...(existing?.items || []), ...incoming.items]
};
}
}
}
}
}
});3. Graph cached templates
Enable cached templates for frequently used queries:
curl -X POST https://cg.optimizely.com/content/v2?stored=true \
-H "Content-Type: application/json" \
-H "Authorization: epi-single YOUR_SINGLE_KEY" \
-H "cg-stored-query: template" \
-d '{
"query": "query GetArticle($key: String!, $locale: [Locales]) { Article(where: { _metadata: { key: { eq: $key } } }, locale: $locale) { item { title, body } } }",
"variables": { "key": "1234567890abcdef1234567890abcdef", "locale": ["en"] }
}'See Cached templates for details.
The shape of the query also affects how well Graph caches the response. Selecting item for a single entity caches and invalidates per document, while items invalidates much more broadly. See Item queries for single entities for the difference between item and items.
Cache invalidation strategies
Webhook-based invalidation
Invalidate cache when content changes:
// api/webhooks/content-updated.js
import { purgeCloudflareCache } from '@/lib/cloudflare';
export default async function handler(req, res) {
const { contentId, contentType } = req.body;
// Verify webhook signature
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Purge CDN cache
await purgeCloudflareCache([
`https://yoursite.com/api/content/${contentId}`,
`https://yoursite.com/${contentType}/${contentId}`
]);
// Invalidate application cache
await cache.invalidate({ id: contentId });
res.status(200).json({ success: true });
}Time-based invalidation (TTL)
Set appropriate TTL based on content type:
function getCacheTTL(contentType) {
const ttlMap = {
StandardPage: 3600, // 1 hour
ArticlePage: 900, // 15 minutes
ProductPage: 300, // 5 minutes
StartPage: 180, // 3 minutes
NavigationBlock: 1800, // 30 minutes
UserProfilePage: 0 // No caching
};
return ttlMap[contentType] || 300;
}
res.setHeader('Cache-Control', `public, s-maxage=${getCacheTTL(contentType)}`);Stale-while-revalidate
Serve stale content while fetching fresh data:
// Serve from cache immediately, update in background
res.setHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=3600');How it works:
- Request comes in after 60s → serve stale version immediately
- Trigger background fetch for fresh data
- Next request gets fresh data
- User never waits for content regeneration
Cache tags for batch invalidation
Tag cache entries for efficient purging:
// Tag cache entries
res.setHeader('Cache-Tag', `content-type:article,category:tech,author:${authorId}`);
// Purge all articles
await purgeByTag('content-type:article');
// Purge by category
await purgeByTag('category:tech');
// Purge by author
await purgeByTag(`author:${authorId}`);Cloudflare integration
Cross-region cache purging
Cloudflare caches content at edge locations globally. Purging propagates to all regions:
// lib/cloudflare.js
export async function purgeCloudflareCache(urls) {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}/purge_cache`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CLOUDFLARE_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ files: urls })
}
);
if (!response.ok) {
throw new Error('Cloudflare purge failed');
}
return response.json();
}Purge options:
// Purge specific URLs
await purgeCloudflareCache([
'https://yoursite.com/page1',
'https://yoursite.com/page2'
]);
// Purge by cache tag
await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ tags: ['article', 'homepage'] })
});
// Purge everything (use sparingly!)
await fetch(`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ purge_everything: true })
});Purge propagation time:
- Single URL purge: ~2-5 seconds globally
- Tag-based purge: ~10-30 seconds globally
- Full purge: ~30-60 seconds globally
Cloudflare Cache Rules
Configure caching behavior in the Cloudflare dashboard or through the API:
// Bypass cache for dynamic content
if (req.url.includes('/api/user/')) {
res.setHeader('Cache-Control', 'private, no-cache');
}
// Cache static content aggressively
if (req.url.includes('/static/')) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}Page Rules example:
URL: yoursite.com/api/*
Settings: Cache Level = Bypass
URL: yoursite.com/static/*
Settings: Cache Level = Cache Everything, Edge Cache TTL = 1 month
URL: yoursite.com/*
Settings: Browser Cache TTL = 4 hours, Edge Cache TTL = 2 hours
Cache strategy by content type
Marketing pages
// Long cache, webhook invalidation
res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
res.setHeader('Cache-Tag', 'marketing-page');Blog posts
// Medium cache, periodic revalidation
res.setHeader('Cache-Control', 'public, s-maxage=900, stale-while-revalidate=3600');
res.setHeader('Cache-Tag', `article,category:${category}`);Product pages
// Short cache due to inventory changes
res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=1800');
res.setHeader('Cache-Tag', `product,category:${category}`);Homepage
// Very short cache, frequent updates
res.setHeader('Cache-Control', 'public, s-maxage=180, stale-while-revalidate=900');
res.setHeader('Cache-Tag', 'homepage');API responses
// Varies by endpoint
function getApiCacheHeaders(endpoint) {
if (endpoint.includes('/user/')) {
return 'private, no-cache'; // No caching for user data
}
if (endpoint.includes('/search/')) {
return 'public, s-maxage=300'; // 5 min for search results
}
return 'public, s-maxage=60'; // 1 min default
}Monitoring cache performance
Cache hit rate
Track how often content is served from cache:
// Cloudflare Analytics API
async function getCacheHitRate() {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/analytics/dashboard`,
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
const data = await response.json();
return {
cacheHitRate: data.result.totals.requests.cached / data.result.totals.requests.all,
cachedRequests: data.result.totals.requests.cached,
totalRequests: data.result.totals.requests.all
};
}Target metrics:
- Cache hit rate > 80% for static content
- Cache hit rate > 60% for dynamic content
- Time to first byte < 200ms for cached content
Common pitfalls
1. Caching personalized content
// ❌ Bad: Everyone sees the same recommendations
app.get('/api/recommendations', cacheMiddleware(300), async (req, res) => {
const recommendations = await getRecommendations(req.user.id);
res.json(recommendations);
});
// ✅ Good: No caching for personalized content
app.get('/api/recommendations', async (req, res) => {
res.setHeader('Cache-Control', 'private, no-cache');
const recommendations = await getRecommendations(req.user.id);
res.json(recommendations);
});2. Inconsistent cache keys
// ❌ Bad: user ID in the key stores a separate copy of the same shared
// article list for every user, and no single key can invalidate them all
cache.set(`user-$USERID-articles`, data);
// ✅ Good: key on what the content actually depends on, so every user
// shares one entry that a single purge can invalidate
cache.set(`articles-category-${category}-page-${page}`, data);3. Not invalidating related content
// ❌ Bad: Only invalidate the updated article
await purgeCache(`/articles/${articleId}`);
// ✅ Good: Invalidate related pages
await purgeCache([
`/articles/${articleId}`,
`/category/${article.category}`,
`/author/${article.authorId}`,
`/` // Homepage might feature this article
]);4. Over-aggressive caching
// ❌ Bad: Caching for too long
res.setHeader('Cache-Control', 'public, s-maxage=86400'); // 24 hours
// ✅ Good: Appropriate TTL with revalidation
res.setHeader('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');Decision tree
Is content personalized?
├─ Yes → No caching (private, no-cache)
└─ No → Continue
Does content change frequently (< every 5 min)?
├─ Yes → No caching or very short TTL (60s)
└─ No → Continue
Is content high-traffic?
├─ Yes → Cache with stale-while-revalidate
└─ No → Continue
Can you predict when content updates?
├─ Yes → Cache with webhook invalidation
└─ No → Cache with short TTL (5-15 min)
Next steps
- Learn about Cached templates for query-level caching
- Review Fallback cache logic for resilience
- Explore Performance and monitoring for optimization strategies
Updated 1 day ago
