Next.js Caching: How to Fix revalidate Failures and Stale Data

SitePoint TeamPublished inJavaScript·Programming·Web·
October 15, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
This article targets Next.js 15.1+, App Router only. Caching behavior differs in Next.js 13 and 14; consult the relevant migration guides if you are on an earlier version.
Table of Contents
Why Your Revalidated Pages Stay Stale
Stale data that ignores revalidate is one of the most common Next.js support issues. A developer sets revalidate = 60, deploys to production, and pages never reflect the latest content. Or worse, a page that was serving perfectly valid content silently transforms into a cached 404. When Next.js caching revalidate is not working, the root cause is rarely a single misconfiguration. It is nearly always an interaction between multiple cache layers, each with its own lifetime and invalidation logic.
The Next.js App Router maintains four distinct caching mechanisms: the Router Cache (client-side, in-memory), the Full Route Cache (server-side rendered HTML and RSC payload), the Data Cache (individual fetch response storage), and Request Memoization (per-request deduplication). A page can appear stale because any one of these layers holds an outdated response, and diagnosing the responsible layer requires systematic work.
This article walks through the most common reasons ISR revalidation fails, exposes a documented but underappreciated pattern where transient upstream outages produce permanently cached 404s, and provides a copy-paste-safe data-fetching helper that prevents it. A diagnostic decision tree is included as a reference for debugging future issues.
How Next.js ISR and revalidate Actually Work
The Three Cache Layers That Matter
Understanding why next.js ISR stale data appears requires knowing where cached content lives.
Next.js stores individual fetch responses in the Data Cache, keyed by URL and request options. When a fetch call includes next: { revalidate: 60 }, the Data Cache holds that response for 60 seconds before marking it stale. Next.js persists this cache across requests and deployments on platforms like Vercel.
The Full Route Cache stores the complete rendered output of a route, both the HTML and the React Server Component payload. For statically generated routes, Next.js populates this cache at build time. For ISR routes, Next.js populates it on the first request and refreshes it according to the revalidate interval. This is the cache most developers think of when they configure ISR.
On the client side, the Router Cache lives entirely in-memory in the browser. In Next.js 15 the staleTimes defaults are 0 seconds for dynamic pages (not cached) and 5 minutes for static pages and fully prefetched links; 14.x used 30 seconds for dynamic pages . Check the values for your installed version. A page might be fresh on the server but stale in the Router Cache, leading to confusion during manual testing
A page can be “correct” in the Data Cache but outdated in the Full Route Cache, or fresh on the server but stale in the client’s Router Cache. Each layer must be considered independently during debugging.
What Happens During a Revalidation Cycle
Next.js ISR follows a stale-while-revalidate model. When a request arrives for a page whose revalidate interval has elapsed, Next.js serves the existing cached page immediately to the visitor. Simultaneously, it triggers a background re-render of the page.
If the background render succeeds, the newly rendered page replaces the old cached page. Subsequent visitors receive the fresh content.
If it throws an error, Next.js retains the previously cached page and logs the error. The stale page keeps serving, which is generally the desired behavior during transient failures.
If the background render calls notFound(), however, the behavior changes in a way that catches many developers off guard. This is where the 404 caching trap lives, and it is the single most damaging failure mode in ISR.
Common Reasons revalidate Is Not Working
1. Conflicting or Missing revalidate Values
The most frequent cause of nextjs revalidate not working is a configuration conflict between route-segment-level settings and fetch-level settings. Within a segment tree, the lowest export const revalidate value wins. However, fetch-level next: { revalidate } settings apply per-request and can differ from the segment default. A missing value on a nested segment can silently inherit from the parent, producing unexpected intervals.
More subtly, mixing fetch-level caching directives with route-segment config can cause one to override the other entirely:
exportconst revalidate =60;exportdefaultasyncfunctionPage(){const res =awaitfetch('https://api.example.com/posts',{cache:'no-store',});const posts =await res.json();return<PostList posts={posts}/>;}exportconst revalidate =60;exportdefaultasyncfunctionPage(){const res =awaitfetch('https://api.example.com/posts',{next:{ revalidate:60},});const posts =await res.json();return<PostList posts={posts}/>;}Setting cache: 'no-store' on a fetch call opts that fetch out of the Data Cache and causes the route to be treated as dynamic, bypassing the Full Route Cache. The segment-level export const revalidate cannot override a no-store directive on an individual fetch.
2. Dynamic Route Params Preventing Static Generation
Calling cookies(), headers(), or accessing un-cached searchParams within a route component opts the route into dynamic rendering. This silently bypasses the Full Route Cache entirely, meaning ISR never engages.
The quickest diagnostic is checking the build output. Next.js marks each route in build output with symbols indicating static or dynamic status. In Next.js 13/14, ○ indicates static and λ indicates dynamic. Next.js 15 prints ○ for static, ● for SSG with params and ƒ for dynamic routes; the legend printed at the end of next build lists the symbols for your installed version. If a route expected to be statically generated shows a dynamic indicator, something in its render path is triggering dynamic behavior.
3. Deployment Platform Cache Headers
Even when Next.js caching is configured correctly, your deployment platform may override these headers. Vercel, Cloudflare, and self-hosted setups each add their own Cache-Control headers. A CDN s-maxage value that outlives the revalidate interval will serve stale HTML from the CDN edge regardless of whether Next.js has regenerated the page on the origin.
The x-nextjs-cache response header is an essential debugging signal. It returns HIT when the response comes from cache, STALE when the cached response is being served while revalidation occurs in the background, and MISS when the page is rendered fresh. If this header shows STALE but the page content never updates, the problem is likely upstream in a CDN layer. The x-nextjs-cache header is reliably emitted on Vercel. On self-hosted deployments, confirm your reverse proxy forwards it.
4. On-Demand Revalidation Pitfalls
revalidatePath purges the Full Route Cache for a specific path, while revalidateTag invalidates all cached fetch responses and unstable_cache entries tagged with a specific string. Using the wrong granularity, or forgetting to tag fetch calls, is a common
Another subtle issue: protect on-demand revalidation routes with a shared secret token to prevent unauthorized cache purges. Both revalidatePath and revalidateTag return void, so there is no result to check. Wrap the calls in try/catch and log caught errors. In Next.js 16, revalidateTag takes a second profile argument (the docs recommend "max") and the one-argument form is deprecated.
The notFound() Caching Trap: Turning Transient Errors into Permanent 404s
How notFound() Behaves During Prerendering vs. Streaming
The notFound() function internally throws a special error to signal a 404 response. This is an internal implementation detail subject to change; rely on the documented behavior described in the Next.js notFound API reference, not on the internal error string. Its behavior depends on when it is called during the rendering lifecycle.
During prerendering or static generation, calling notFound() causes Next.js to serve a static 404 page with a 404 HTTP status code and cache that 404 as the route’s canonical response.
Inside a Suspense boundary after streaming has already begun, Next.js cannot change the HTTP status code because headers have already been sent with a 200 status. Instead, it injects a <meta name="robots" content="noindex" /> tag into the response. This is a subtly different failure mode: the page appears to load successfully to the browser but tells search engines to deindex it, potentially causing SEO damage that goes undetected for multiple crawl cycles, depending on your site’s crawl frequency. This behavior is documented in the Next.js notFound API reference.
The Reported Pattern: CMS Downtime Produces a Cached 404
This is the next.js notFound caching trap, and it follows a predictable sequence:
- A CMS or database goes down for 30 seconds due to a transient outage.
- During that window, an ISR background revalidation fires for a popular page.
- The
fetchcall returns no data (or a network error that the code treats as “no data”). - The developer’s code checks
if (!data)and callsnotFound(). - Next.js caches the 404 response as the new valid response for that route, evicting the previously cached good page.
- The CMS comes back online, but the next.js ISR 404 cached response persists until another revalidation cycle succeeds, or until someone manually triggers on-demand revalidation.
I reproduced this on Next.js 15.5.28 with next build && next start, a page with revalidate = 3, and a local CMS stub I switched to returning 500. When the page called notFound() on the failed fetch, the next response was a 404 served from the cache (x-nextjs-cache: HIT), and it stayed a 404 until a later revalidation succeeded after the stub came back. When the same page threw an error instead, every response kept serving the previous good page through the outage. Your platform and version may differ, so test it on yours.
Never let a transient upstream failure produce a
notFound()call. Throw an error instead, and let Next.js serve the stale page while logging the failure so you can investigate.
import{ notFound }from'next/navigation';exportconst revalidate =60;exportdefaultasyncfunctionPostPage({params,}:{params:Promise<{ slug:string}>;}){const{ slug }=await params;const res =awaitfetch(`https://cms.example.com/posts/${slug}`);if(!res.ok){if(res.status ===404){notFound();}thrownewError(`Upstream error:${res.status}`);}const post =await res.json();if(!post){notFound();}return<Article post={post}/>;}Diagnostic Decision Tree
When debugging stale pages or unexpected 404s, work through the following decision path:
Step 1: Check the build output. Run next build with production environment variables set, as environment-dependent dynamic API calls affect route classification. Is the route marked as static or dynamic? If dynamic, ISR is not engaged. Look for cookies(), headers(), searchParams, or cache: 'no-store' in the render path.
Step 2: Inspect cache headers. Does the x-nextjs-cache header show HIT, STALE, or MISS? If STALE persists, check whether background revalidation is failing silently. If the header is absent, a CDN or reverse proxy may be stripping it.
Step 3: Verify revalidate config. Is revalidate set at the correct level (page, layout, or fetch)? Are there conflicting values? Does any fetch call use cache: 'no-store'?
Step 4: Check data-fetch error handling. Does the code call notFound() when upstream data is null? If so, a transient CMS outage can produce a cached 404. This is the most commonly reported branch for pages that “disappear” after working correctly.
Step 5: Verify on-demand revalidation. If using revalidatePath or revalidateTag, confirm the call completes without throwing by wrapping it in try/catch, and confirm that fetch calls are properly tagged.
Step 6: Check for middleware rewrites. Middleware (called Proxy in Next.js 16) that rewrites URLs can alter the cache key, causing revalidation to target a different cache entry than expected.
A Safer Data-Fetching Helper That Prevents Cached 404s
Design Goals
The core distinction is between “this rearily unreachable.” The first case warrants notFound(). The second case should throw an error so that Next.js retains the previously cached good page and logs the failure
A helper can encode this logic once and apply across every route in a project.
exportclassFetchErrorextendsError{constructor(publicreadonly phase:'network'|'status'|'parse',publicreadonly status:number|null,publicreadonly hostname:string,message:string){super(message);this.name ='FetchError';}}exportasyncfunctionfetchOrThrow<T>(url:string,options?: RequestInit &{ next?:{ revalidate?:number; tags?:string[]}}):Promise<T|null>{const hostname =(()=>{try{returnnewURL(url).hostname;}catch{return'[invalid-url]';}})();const controller =newAbortController();const timeoutId =setTimeout(()=> controller.abort(),10_000);let res: Response;try{res =awaitfetch(url,{ signal: controller.signal,...options });}catch(error){clearTimeout(timeoutId);const message = error instanceofError? error.message :'Unknown error';thrownewFetchError('network',null,hostname,`[fetchOrThrow] Network error fetching${hostname}:${message}`);}finally{clearTimeout(timeoutId);}if(res.status ===404){returnnull;}if(res.status ===204|| res.status ===205){returnnull;}if(res.status >=500){thrownewFetchError('status',res.status,hostname,`[fetchOrThrow] Server error${res.status}fetching${hostname}`);}if(!res.ok){thrownewFetchError('status',res.status,hostname,`[fetchOrThrow] Unexpected status${res.status}fetching${hostname}`);}let data:T;try{data =(await res.json())asT;}catch(error){const message = error instanceofError? error.message :'Unknown error';thrownewFetchError('parse',res.status,hostname,`[fetchOrThrow] JSON parse error for${hostname}:${message}`);}return data;}The helper returns null only when the upstream server explicitly responds with a 404 status (indicating the resource genuinely does not exist) or with 204/205 (no content). For 5xx errors, other non-OK responses (such as 401, 403, or 429), network failures, or malformed JSON bodies, it throws a structured FetchError, ensuring Next.js treats the revalidation as failed and continues serving the stale-but-valid cached page. All error messages log only the hostname, never the full URL, to prevent leaking secrets such as API keys in query parameters. A 10-second AbortController timeout prevents indefinitely hung requests from blocking ISR workers.
Using the Helper in a Page Component
Note: The import below uses the @/ path alias. Ensure your tsconfig.json includes "paths": { "@/*": ["./*"] }, which is the default for create-next-app projects.
import{ notFound }from'next/navigation';import{ fetchOrThrow }from'@/lib/fetch-or-throw';constREVALIDATE_SECONDS=60;exportconst revalidate =REVALIDATE_SECONDS;constCMS_BASE_URL= process.env.CMS_BASE_URL??'https://cms.example.com';interfacePost{title:string;content:string;}exportdefaultasyncfunctionPostPage({params,}:{params:Promise<{ slug:string}>;}){const{ slug }=await params;if(!slug ||!/^[w-]+$/.test(slug)){notFound();}const post =awaitfetchOrThrow<Post>(`${CMS_BASE_URL}/posts/${slug}`,{ next:{ revalidate:REVALIDATE_SECONDS, tags:['posts']}});if(post ===null){notFound();}return<Article post={post}/>;}If fetchOrThrow throws due to a 500, another non-OK status, or a network error, the error bubbles up, the background revalidation fails, and Next.js serves the previously cached page. The failure is logged server-side for monitoring. Only a confirmed 404 from the upstream API triggers notFound().
Adding Cache Tags for Precise Invalidation
Wrapping the helper with the "use cache" directive or unstable_cache provides an additional layer of control. "use cache" is experimental in Next.js 15 (behind experimental.dynamicIO) and stable in Next.js 16 with cacheComponents: true. unstable_cache is the option for Next.js 15 projects that want to stay off experimental flags. Tagging fetch calls enables precise on-demand invalidation through webhook-triggered API routes. A CMS webhook handler, for example, can call revalidateTag('posts') to purge all cached responses tagged with posts, forcing immediate regeneration on the next request without waiting for the revalidate interval.
Quick-Reference Debugging Checklist
When nextjs revalidate is not working, run through these checks in order:
- Confirm the route is static in build output. Check the legend printed at the end of
next buildfor your version’s symbols indicating static vs. dynamic routes. If the route shows a dynamic indicator, it has been opted into dynamic rendering. - Verify
revalidateis set at the correct level. Check page, layout, and individualfetchcalls for conflicting or missing values. - Check for accidental
cache: 'no-store'or dynamic function usage. A singlecookies(),headers(), orcache: 'no-store'call causes the route to be treated as dynamic. - Inspect the
x-nextjs-cacheresponse header. Look forHIT,STALE, orMISSto determine which cache layer is involved. - Confirm CDN or platform cache headers are not overriding. A CDN
s-maxagelonger thanrevalidatewill serve stale content from the edge. - Ensure
notFound()is only called for genuine 404s, not transient failures. Use thefetchOrThrowpattern to distinguish between the two. - Verify on-demand revalidation completes without error. Both
revalidatePathandrevalidateTagreturnvoid; wrap calls in try/catch and log caught errors. Check thatfetchcalls are properly tagged and that the route handler actually runs the revalidation call. - Test locally with
next build && next start. In most cases,next devdisables the Full Route Cache and Data Cache; always confirm behavior withnext build && next start. - Check for middleware rewrites that alter the cache key. URL rewrites in middleware can cause revalidation to target a different cache entry.
- Review the Next.js version. Caching defaults changed significantly in versions 14.2 and 15, particularly around Router Cache lifetimes, and
"use cache"went from experimental in 15 to stable in 16.
Key Takeaways
Most “revalidate not working” issues trace back to one of three root causes: configuration conflicts between cache layers, accidental opt-in to dynamic rendering, or the notFound() caching trap that converts transient upstream failures into permanent 404s.
The single most important takeaway is this: never let a transient upstream failure produce a
notFound()call. Throw an error instead, and let Next.js serve the stale page while logging the failure so you can investigate.
The fetchOrThrow helper encodes this logic in a pattern that can be adapted to any data source.
The diagnostic decision tree and debugging checklist above provide a systematic path through the most common failure modes. Bookmark them for the next time a production page goes stale or vanishes.
Sharing our passion for building incredible internet things.

