Why cache invalidation matters
ButterCMS uses a content delivery network (CDN) infrastructure to provide content faster. If you request data from Europe and the server is in the United States, you will get the content from one of the cache servers in Europe instead of the original one.The cache staleness problem
Benefits of webhook-based invalidation
| Approach | Pros | Cons |
|---|---|---|
| Time-based expiration | Simple to implement | Stale content during TTL, wasted cache for unchanged content |
| Manual invalidation | Precise control | Human error, delayed updates, doesn’t scale |
| Webhook invalidation | Immediate, automated, precise | Requires endpoint setup |
CDN cache invalidation
Cloudflare
Purge Cloudflare cache when ButterCMS content changes:const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID;
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN;
app.post('/webhooks/buttercms', async (req, res) => {
const { data, webhook } = req.body;
// Determine which URLs to purge based on content type
const urlsToPurge = getUrlsForContent(data, webhook.event);
try {
await purgeCloudflareUrls(urlsToPurge);
console.log('Cloudflare cache purged for:', urlsToPurge);
res.status(200).json({ purged: true });
} catch (error) {
console.error('Cache purge failed:', error);
res.status(500).json({ error: 'Purge failed' });
}
});
function getUrlsForContent(data, event) {
const baseUrl = process.env.SITE_URL;
const urls = [];
const [contentType] = event.split('.');
switch (contentType) {
case 'page':
// Purge the specific page
urls.push(`${baseUrl}/${data.id}`);
// Purge any listing pages that might show this page
urls.push(`${baseUrl}/`);
if (data.page_type !== '*') {
urls.push(`${baseUrl}/${data.page_type}`);
}
break;
case 'post':
// Purge the specific blog post
urls.push(`${baseUrl}/blog/${data.id}`);
// Purge blog listing pages
urls.push(`${baseUrl}/blog`);
urls.push(`${baseUrl}/blog/`);
// Purge RSS feed
urls.push(`${baseUrl}/blog/rss.xml`);
break;
case 'collectionitem':
// Purge pages that use this collection
urls.push(`${baseUrl}/*`); // May need to purge broadly
break;
}
// Handle localized content
if (data.locale) {
urls.push(...urls.map(url => url.replace(baseUrl, `${baseUrl}/${data.locale}`)));
}
return urls;
}
async function purgeCloudflareUrls(urls) {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ files: urls })
}
);
if (!response.ok) {
throw new Error(`Cloudflare purge failed: ${response.statusText}`);
}
return response.json();
}
Fastly
const FASTLY_SERVICE_ID = process.env.FASTLY_SERVICE_ID;
const FASTLY_API_KEY = process.env.FASTLY_API_KEY;
async function purgeFastlyUrls(urls) {
for (const url of urls) {
await fetch(url, {
method: 'PURGE',
headers: {
'Fastly-Key': FASTLY_API_KEY
}
});
}
}
// For surrogate keys (more efficient)
async function purgeFastlySurrogateKey(key) {
await fetch(
`https://api.fastly.com/service/${FASTLY_SERVICE_ID}/purge/${key}`,
{
method: 'POST',
headers: {
'Fastly-Key': FASTLY_API_KEY
}
}
);
}
AWS CloudFront
const AWS = require('aws-sdk');
const cloudfront = new AWS.CloudFront();
async function purgeCloudFrontPaths(paths) {
const params = {
DistributionId: process.env.CLOUDFRONT_DISTRIBUTION_ID,
InvalidationBatch: {
CallerReference: Date.now().toString(),
Paths: {
Quantity: paths.length,
Items: paths
}
}
};
const result = await cloudfront.createInvalidation(params).promise();
console.log('CloudFront invalidation created:', result.Invalidation.Id);
return result;
}
app.post('/webhooks/buttercms', async (req, res) => {
const { data, webhook } = req.body;
const paths = webhook.event.includes('post')
? ['/blog/*', '/blog']
: [`/${data.id}`, `/${data.id}/*`];
await purgeCloudFrontPaths(paths);
res.status(200).json({ invalidated: true });
});
Application-level cache invalidation
Redis cache
const Redis = require('ioredis');
const redis = new Redis();
app.post('/webhooks/buttercms', async (req, res) => {
const { data, webhook } = req.body;
try {
await invalidateRedisCache(data, webhook.event);
res.status(200).json({ invalidated: true });
} catch (error) {
console.error('Redis invalidation failed:', error);
res.status(500).json({ error: 'Invalidation failed' });
}
});
async function invalidateRedisCache(data, event) {
const [contentType] = event.split('.');
// Build cache key patterns to invalidate
const patterns = [];
switch (contentType) {
case 'page':
patterns.push(`page:${data.id}:*`);
patterns.push(`pages:${data.page_type}:*`);
patterns.push('pages:list:*');
break;
case 'post':
patterns.push(`post:${data.id}:*`);
patterns.push('posts:list:*');
patterns.push('posts:recent:*');
break;
case 'collectionitem':
patterns.push(`collection:${data.id}:*`);
break;
}
// Handle locale-specific caches
if (data.locale) {
patterns.push(...patterns.map(p => p.replace(':*', `:${data.locale}:*`)));
}
// Delete matching keys
for (const pattern of patterns) {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
console.log(`Deleted ${keys.length} keys matching ${pattern}`);
}
}
}
Memcached
const Memcached = require('memcached');
const memcached = new Memcached('localhost:11211');
async function invalidateMemcachedKeys(keys) {
return Promise.all(
keys.map(key =>
new Promise((resolve, reject) => {
memcached.del(key, (err) => {
if (err) reject(err);
else resolve();
});
})
)
);
}
In-memory cache (Node.js)
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 3600 });
app.post('/webhooks/buttercms', async (req, res) => {
const { data, webhook } = req.body;
const [contentType] = webhook.event.split('.');
// Get all cache keys
const allKeys = cache.keys();
// Filter keys related to this content
const keysToDelete = allKeys.filter(key => {
if (contentType === 'page') {
return key.includes(`page:${data.id}`) || key.includes('pages:');
}
if (contentType === 'post') {
return key.includes(`post:${data.id}`) || key.includes('posts:');
}
if (contentType === 'collectionitem') {
return key.includes(`collection:${data.id}`);
}
return false;
});
// Delete keys
cache.del(keysToDelete);
console.log('Invalidated cache keys:', keysToDelete);
res.status(200).json({ invalidated: keysToDelete.length });
});
Static site rebuilds
For static site generators (Next.js, Gatsby, Hugo), trigger a rebuild when content changes:Vercel deploy hooks
const VERCEL_DEPLOY_HOOK = process.env.VERCEL_DEPLOY_HOOK;
app.post('/webhooks/buttercms', async (req, res) => {
const { webhook } = req.body;
// Only rebuild on publish events
if (webhook.event.includes('published')) {
await fetch(VERCEL_DEPLOY_HOOK, { method: 'POST' });
console.log('Vercel rebuild triggered');
}
res.status(200).json({ rebuilt: true });
});
Netlify build hooks
const NETLIFY_BUILD_HOOK = process.env.NETLIFY_BUILD_HOOK;
app.post('/webhooks/buttercms', async (req, res) => {
const { webhook } = req.body;
// Trigger rebuild on content changes
if (['published', 'unpublished', 'delete'].some(e => webhook.event.includes(e))) {
await fetch(NETLIFY_BUILD_HOOK, { method: 'POST' });
console.log('Netlify rebuild triggered');
}
res.status(200).json({ triggered: true });
});
Next.js on-demand ISR
For Next.js apps using Incremental Static Regeneration:// pages/api/revalidate.js
export default async function handler(req, res) {
// Verify webhook secret
if (req.headers['x-webhook-secret'] !== process.env.WEBHOOK_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { data, webhook } = req.body;
const [contentType] = webhook.event.split('.');
try {
// Revalidate specific paths
if (contentType === 'page') {
await res.revalidate(`/${data.id}`);
} else if (contentType === 'post') {
await res.revalidate(`/blog/${data.id}`);
await res.revalidate('/blog'); // Revalidate listing page
}
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).json({ error: 'Revalidation failed' });
}
}
Smart cache invalidation strategies
Tag-based invalidation
Use cache tags to group related content for efficient invalidation:// When caching content, add tags
function cacheWithTags(key, data, tags) {
// Store the data
cache.set(key, data);
// Track which keys belong to which tags
for (const tag of tags) {
const tagKey = `tag:${tag}`;
const taggedKeys = cache.get(tagKey) || [];
taggedKeys.push(key);
cache.set(tagKey, taggedKeys);
}
}
// Invalidate by tag
function invalidateByTag(tag) {
const tagKey = `tag:${tag}`;
const keysToInvalidate = cache.get(tagKey) || [];
cache.del(keysToInvalidate);
cache.del(tagKey);
return keysToInvalidate.length;
}
// Usage in webhook handler
app.post('/webhooks/buttercms', (req, res) => {
const { data, webhook } = req.body;
// Invalidate by content tags
const invalidated = invalidateByTag(`content:${data.id}`);
// Also invalidate related tags
if (data.page_type) {
invalidateByTag(`page-type:${data.page_type}`);
}
res.status(200).json({ invalidated });
});
Cascade invalidation
When one piece of content changes, invalidate related content:async function cascadeInvalidation(data, event) {
const invalidations = [];
// Primary invalidation
invalidations.push(invalidateContent(data.id));
// Cascade to related content
if (event.includes('collectionitem')) {
// Find all pages that reference this collection
const referencingPages = await findPagesReferencingCollection(data.id);
for (const page of referencingPages) {
invalidations.push(invalidateContent(page.slug));
}
}
// Invalidate navigation if relevant
if (data.page_type === 'navigation') {
invalidations.push(invalidateContent('global-nav'));
invalidations.push(invalidateAll()); // Navigation affects all pages
}
await Promise.all(invalidations);
}
Debounced invalidation
Prevent excessive cache clears during bulk content updates:const debounce = require('lodash.debounce');
// Accumulate invalidations and process in batches
const pendingInvalidations = new Set();
const processInvalidations = debounce(async () => {
const items = Array.from(pendingInvalidations);
pendingInvalidations.clear();
// Batch invalidate
await batchInvalidate(items);
console.log(`Batch invalidated ${items.length} items`);
}, 5000); // Wait 5 seconds for more items
app.post('/webhooks/buttercms', (req, res) => {
const { data, webhook } = req.body;
// Add to pending set
pendingInvalidations.add(`${webhook.event}:${data.id}`);
// Trigger debounced processing
processInvalidations();
// Acknowledge immediately
res.status(200).json({ queued: true });
});