deniz.in

Markets

Weather

Loading weather

· via dev.to (home feed)

Next.js ISR pages on Vercel stay stale for days on low-traffic routes

A dev.to walkthrough explains why Next.js pages with a five-minute revalidate can serve content that is days old on Vercel: ISR regeneration only runs when a request arrives.

Next.js ISR pages on Vercel stay stale for days on low-traffic routes

The symptom

A developer running a Next.js content site on Vercel — a few hundred pages, App Router, a revalidate window of 300 seconds — updated a figure on one detail page and waited. Five minutes passed; the page reloaded with the old value. An hour later, still the old value. The natural conclusion is that ISR is broken. According to a write-up on dev.to, the real explanation is simpler and more uncomfortable: hardly anyone visits that page, and ISR on Vercel only refreshes a cached page when a request comes in.

Diagnose from the headers first

The post's central debugging tip is to stop guessing and read the HTTP response headers. A curl -I against any Next.js page on Vercel surfaces four values that together explain almost everything:

  • x-nextjs-prerender: 1 — the page was prerendered, so ISR semantics apply. If this header is absent, the route is dynamically rendered and the rest is irrelevant.
  • x-nextjs-stale-time — the configured revalidate value, in seconds. It describes a window, not a schedule.
  • x-vercel-cache — HIT, STALE or MISS: whether the edge served a fresh copy, served an expired copy while kicking off a regeneration, or had to reach the origin.
  • age — how many seconds ago that cached entry was created.

In the author's case, a page whose stale-time was 300 seconds reported an age of 529,343 — just over six days.

Regeneration runs on requests, not on a timer

There is no background job in Next.js or Vercel that walks your routes and rebuilds them as they expire. What actually happens per request:

  1. The edge compares the cached entry's age against the stale-time.
  2. Inside the window: serve the cached copy, done.
  3. Outside the window: serve the stale copy immediately and start a regeneration in the background.
  4. The next request, some time later, is the one that receives the fresh output.

Step 3 is what surprises people: the request that discovers the staleness still gets the old content — it merely pays the cost of triggering a rebuild for whoever comes next.

Measured across five routes on a single deployment, all sharing the same 300-second revalidate:

Route age x-vercel-cache
/ 64s HIT
/list 3,945s STALE
/index-page 3,945s STALE
/detail/one-item 529,343s STALE
/about 1,207,507s HIT

The homepage gets constant traffic, so it is never more than a minute or two old. The detail page is six days old and the about page roughly fourteen days old, purely because almost nobody requests them. As the post frames it, revalidate: 300 does not mean "this page is at most five minutes old" — it means "once someone requests this page more than five minutes after it was cached, start rebuilding it". For a popular route those are effectively the same statement; on the long tail they differ by days.

Where it bites

For most content this is a sensible trade: responses stay fast, origin load stays low, and pages that matter stay fresh because traffic keeps refreshing them. It stops being sensible when visitors act on what they read — prices, availability, ratings, rankings — where a six-day-old page is misleading rather than merely outdated. The author's detail pages carry changing pricing and ratings, so the assumed five-minute freshness was in practice freshness as of the last visitor.

There is also a deployment trap: ship a fix, check the page, see the old version and conclude the deploy failed. It did not fail — your request triggered the rebuild, and the change appears on the next load.

What to do instead

Use on-demand revalidation for data you control. When the underlying record changes, tell Next.js explicitly from whatever writes the data — a CMS webhook, an admin action, an import script:

js import { revalidatePath } from 'next/cache'

export async function POST(request: Request) { const { slug } = await request.() revalidatePath(/detail/${slug}) return Response.({ revalidated: true }) }

Prefer revalidateTag when one change fans out across routes: tag the fetches with { next: { tags: ['catalogue'] } } and call revalidateTag('catalogue') when the data changes, which rebuilds every dependent page at once.

Two further recommendations from the post: do not just shrink the revalidate value — dropping it from 300 to 60 changes nothing for a page nobody visits and only adds load on pages that were already fine. And monitor the age header: a small script in CI or on a cron that curls important routes and alerts when age exceeds some multiple of stale-time will catch the problem before a user does.

Why it matters

ISR's lazy model is a deliberate performance trade, yet it is easy to read revalidate as a freshness guarantee it never makes. Any App Router site with a long tail of pages plus data that users act on — commerce, listings, anything price- or score-driven — should treat freshness as event-driven and push revalidation from the data layer rather than waiting for traffic. And when a cached page looks wrong, the response headers tell you within seconds whether you have a caching problem, a traffic problem, or a deploy problem.

  • #next-js
  • #vercel
  • #caching
  • #isr
  • #app-router

Related posts