Skip to main content

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

ApproachProsCons
Time-based expirationSimple to implementStale content during TTL, wasted cache for unchanged content
Manual invalidationPrecise controlHuman error, delayed updates, doesn’t scale
Webhook invalidationImmediate, automated, preciseRequires 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 });
});