Dev guideRecipesAPI ReferenceChangelog
Dev guideRecipesUser GuidesNuGetDev CommunityOptimizely AcademySubmit a ticketLog In
Dev guide

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 hours

Best 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:

DirectiveDescriptionWhen to use
publicCan be cached by any cachePublic content
privateOnly browser cache, not CDNUser-specific content
s-maxage=NCDN cache duration (seconds)All public content
max-age=NBrowser cache durationStatic assets
stale-while-revalidate=NServe stale while fetching freshHigh-traffic pages
no-cacheRevalidate before servingFrequently updated
no-storeNever cacheSensitive 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:

  1. Request comes in after 60s → serve stale version immediately
  2. Trigger background fetch for fresh data
  3. Next request gets fresh data
  4. 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


Did this page help you?