← Back to Journal

leptons1618@anishgiri.dev:~/journal/cache-bursting-in-next-js-what-happened-and-how-i-fixed-it less post.md

#NEXT.JS#CACHING#ISR#PERFORMANCE

Cache Bursting in Next.js: What Happened and How I Fixed It

I diagnosed a cache-bursting issue in our Next.js dashboard, traced it to stale ISR revalidation, and fixed it with targeted revalidate values and an on-demand API route.

Last Tuesday I noticed the homepage of our internal dashboard kept serving stale data after a deploy, even though we had set revalidate: 60 in getStaticProps. Users complained they saw yesterday’s metrics while the API returned fresh numbers. I opened the Network tab, saw a 200 response from the page, but the JSON payload inside the HTML matched the old build. That meant the HTML was being served from the edge cache, not regenerated.

The Problem

Our site uses Incremental Static Regeneration (ISR) on the home page. In pages/index.js we had:

export async function getStaticProps() {
  const res = await fetch('https://api.internal.example.com/metrics', {
    headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
  });
  const data = await res.json();

  return {
    props: { metrics: data },
    revalidate: 60, // seconds
  };
}

We deployed to Vercel with the default edge caching layer. After each deploy, the first request would hit the origin, generate fresh HTML, and cache it for 60 seconds. However, we observed bursts where the cache was cleared for many users at once, causing a thundering herd of requests to the API and a spike in latency from 200 ms to over 2 seconds.

Digging into the Logs

I pulled Vercel logs for the / route over a 10-minute window after a deploy. The log lines showed:

[2024-09-24T10:15:03.123Z] "GET /" 200 - edge-cache-hit
[2024-09-24T10:15:04.456Z] "GET /" 200 - edge-cache-miss
[2024-09-24T10:15:04.789Z] "GET /api/internal/metrics" 200 - origin-fetch
...

The cache-miss spikes coincided with deploy timestamps. The revalidate: 60 setting meant that after 60 seconds the next request would trigger regeneration, but because we had a high traffic burst right after deploy, many requests arrived before the first regeneration completed, each seeing a cache-miss and hitting the API.

I also checked our next.config.js:

module.exports = {
  reactStrictMode: true,
};

No custom caching headers were set, so Vercel’s default Cache-Control: s-maxage=86400, stale-while-revalidate=43200 applied to the edge layer. That meant the edge could hold a stale copy for up to 24 hours while waiting for a background revalidate, but the first request after a deploy still bypassed the edge and went to origin because the cached HTML was invalidated by the new deployment.

The Fix

I took two steps:

  1. Narrow the revalidation window to match our data refresh rate. The metrics endpoint updates every 30 seconds, so I changed revalidate to 30. This reduces the window where a stale copy can be served after a deploy, but more importantly it aligns the ISR cycle with the data source, lowering the chance of a burst when the cache expires.
export async function getStaticProps() {
  const res = await fetch('https://api.internal.example.com/metrics', {
    headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
  });
  const data = await res.json();

  return {
    props: { metrics: data },
    revalidate: 30, // now matches the API refresh interval
  };
}
  1. Add an on-demand revalidation API route that we call from our CI pipeline after a deploy. This warms the cache instantly, so the first real user gets a hit. I created pages/api/revalidate-home.js:
export default async function handler(req, res) {
  // Secret token to prevent public calls
  if (req.query.token !== process.env.REVALIDATE_TOKEN) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  try {
    await res.unstable_revalidate('/');
    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).send('Error revalidating');
  }
}

In our Vercel deploy hook we added a curl step:

curl "https://our-app.vercel.app/api/revalidate-home?token=$REVALIDATE_TOKEN"

This runs right after the build finishes, triggering ISR to generate a fresh page before any user traffic arrives.

To make sure the edge layer didn’t serve a stale copy during the brief window between the deploy and the webhook, I added a custom Cache-Control header via headers() in next.config.js:

const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, s-maxage=60, stale-while-revalidate=30',
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

This tells Vercel to keep the edge copy for only 60 seconds and allow a stale-while-revalidate of 30 seconds, which works well with our 30-second ISR cycle.

Results

After deploying the changes, I monitored the same 10-minute window after a deploy:

  • Cache-miss ratio dropped from ~60 % to ~5 %.
  • Average TTFB for / fell from 1.8 seconds to 210 milliseconds.
  • API requests to /metrics decreased from an average of 120 req/min to 8 req/min during the post-deploy spike.

Users no longer reported seeing outdated numbers, and the dashboard felt snappy right after each release.

If you’re seeing similar bursts, check three things:

  1. Does your revalidate interval match how often your data actually changes?
  2. Are you warming the cache with an on-demand revalidate after each deploy?
  3. Have you tuned the edge Cache-Control headers to avoid long stale-while-revalidate windows that can hide the problem?

Fixing those three points cleared up the cache-bursting issue for us, and it’s been stable for the past six weeks. Give it a try and let me know how it works for you.