In time based revalidation, an expiry date is added to cache entries. However, when a cache entry expires (becomes stale) it does not update automatically. There are no server agents or CRON jobs that scan for expired cache entries to update them.
Compare this to an expired carton of milk in your fridge. The fact that it expired does not remove it from your fridge. There is no leprechaun doing it for you either.
How is a stale cache entry updated then? It happens at request time. When there is a request for a route, Next checks the full route cache for a match. It then checks the expiry date on this cached route. When it has expired, it will trigger an update.
How to set an expiry date on cache entries?
There are 2 ways to set time based revalidation:
- Set it on the route using a Route Segment option:
export const revalidate = false | 0 | number;
- Set it on an individual fetch, using the options object:
fetch(url, { next: { revalidate: false | 0 | number } });
The value of revalidate can be:
-
revalidate: false: cache, doesn't expire. Same as{ cache: "force-cache" }. -
revalidate: 0: do not cache. Same as{ cache: "no-store" }-> no data cache + force dynamic render. -
revalidate: number: cache for x seconds.
Time based revalidation of full route cache & data cache
Setting a fetch revalidate controls if and how long the fetch will be cached in the data cache. We just saw this in the previous paragraph.
- 0 -> no cache
- false -> cache for infinity
- number -> cache for x seconds
So, time based revalidation targets the data cache. Yes. But time based revalidation also targets the full route cache. Take an example. Imagine we have a route with 3 components: <Users />, <Todos /> and <Posts />. Each component fetches with a different revalidate value:
const users = await fetch(url, { revalidate: 100 }); // <Users />
const todos = await fetch(url, { revalidate: 200 }); // <Todos />
const posts = await fetch(url, { revalidate: 300 }); // <Posts />
Each component then renders out their thing (users.map(user => <>{user}</>)). So, that's our setup. A route with 3 components. All these fetches will be cached in the data cache, each with their own expiry date.
But, the route (that will be statically rendered) also gets an expiry date. Which one? The lowest of all revalidate settings in the route. Given this example, this should make sense.
100 + 1 seconds after build time, the user fetch (data cache) is already stale. But, this means that the full route cache for this route also has to be stale! The prerendered HTML and rsc now show stale data from the user fetch. The expiry date of the route has to equal the lowest revalidate in the route. That is the only way to guarantee that the full route cache is up to date with the data cache.
Back to our example. When a request happens for this route at 100 + 1 seconds:
-
Nextchecks thefull route cachefor this route, notices it's expired and statically renders the route again. - It will run each fetch again:
- fetch users: check data cache -> 100 -> expired -> new fetch
- fetch todos: check data cache -> 200 -> not expired (100 - 1s left) -> use cached data
- fetch posts: check data cache -> 300 -> not expired (200 - 1s left) -> use cached data
-
Nextfinishes rendering the route and saves it in cache (ISR).
At 200 + 1 seconds, a new request.
-
Nextchecks thefull route cachefor this route, notices it's expired and statically renders the route again. - It will run each fetch again:
- fetch users: check data cache -> 100 -> expired again -> new fetch
- fetch todos: check data cache -> 200 -> expired -> new fetch
- fetch posts: check data cache -> 300 -> not expired (100 - 1s left) -> use cached data
-
Nextfinishes rendering the route and saves it in cache (ISR).
At 300 + 1 seconds, a new request.
-
Nextchecks thefull route cachefor this route, notices it's expired and statically renders the route again. - It will run each fetch again:
- fetch users: check data cache -> 100 -> expired again -> new fetch
- fetch todos: check data cache -> 200 -> not expired (100 - 1s left) -> use cached data
- fetch posts: check data cache -> 300 -> expired -> new fetch
-
Nextfinishes rendering the route and saves it in cache (ISR).
Does this make sense? Time based revalidation sets expiry dates on the data cache and on the full route cache. When a data cache entry goes stale in a statically rendered route, then the data cache entry AND the route in the full route cache have to be updated. Rerendering the route does not automatically invalidate all data cache entries. Stale data cache entries will be fetched again. data cache entries that are not expired will be reused.
This is how revalidation works in routes that have data cache and full route cache. In routes that are dynamically rendered (no full route cache), revalidation will obviously not target the full route cache since there is none. Dynamic routes are freshly rendered at each request.
The next combination is full route cache but no data cache. For example:
export const revalidate = 100;
export default async function page() {
// Database query or some ORM
const users = await db.query('SELECT * FROM users');
return <div>Hello world!</div>;
}
The important thing to notice: we're using a database or ORM here. We do not use the fetch API. This means: no data cache but also no fetch revalidate value. We use route segment option revalidation. This static route will expire after 100 seconds. The time based revalidation in this case targets only the full route cache.
The last possible combination is dynamic routes with no data cache. No cache, no revalidation needed. Time based revalidation doesn't work here.
Some tests
Note: the examples are available on github. Let's first take a look at a generated data cache file. We're going to reuse our <Post /> component we used in an earlier chapter:
// components/Post.tsx
type Props = {
postId: number;
fetchOptions: RequestInit;
};
export type PostT = {
id: number;
title: string;
};
export default async function Post({ postId, fetchOptions }: Props) {
const data = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`,
fetchOptions,
);
const post: PostT = await data.json();
// get date from headers
const fetchDate = data.headers.get('date');
const time = fetchDate
? new Date(fetchDate).toTimeString().slice(0, 8)
: null;
return (
<div className='flex gap-2'>
<div className='text-orange-400 mr-2'>{time}</div>
<div className='font-bold'>{post.id}.</div>
<div className='italic'>{post.title}</div>
</div>
);
}
And we load this inside this static route:
// app/revalidate/time/static/page.tsx
import Post from '@/components/Post';
export default function Page() {
return <Post postId={20} fetchOptions={{ next: { revalidate: 1800 } }} />;
}
We set revalidate to 1800 seconds (30 mins) and we run next build.
The first interesting thing happens in the Next build log:
Route (app) Revalidate Expire
├ ○ /revalidate/time/static 30m 1y
○ (Static) prerendered as static content
The Revalidate value 30m (30 minutes) is what we set as revalidate in our fetch. This is the expiry date of the route in the full route cache. We talked about this earlier. This is equal to the lowest revalidate value in the entire route.
The expire value of 1y is something different. It is a default setting. It defines how long Next will keep a version of that page in cache if no one visits it. You can ignore this.
If we look into the generated data cache file we see this:
// ...
"headers": {
"date":"Fri, 20 Mar 2026 17:11:02 GMT",
}
// ...
"url":"https://jsonplaceholder.typicode.com/posts/20",
"revalidate":1800
// ...
We run next start and visit this route: /revalidate/time/static:
Note how the time from the headers and the screenshot are the same. When we do a hard refresh of the page, they stay the same ... because they are cached. We are getting served the route from full route cache. This has a revalidate of 30 minutes. As long as this route doesn't expire, we keep getting served the same HTML and rsc.
We copy this in a dynamic route:
// app/revalidate/time/dynamic/page.tsx
import Post from '@/components/Post';
export const dynamic = 'force-dynamic';
export default function Page() {
return <Post postId={20} fetchOptions={{ next: { revalidate: 1800 } }} />;
}
Note that we use the same id and fetchOptions as the static route. We run build and start and visit this new dynamic route. What happens? We see the same data. We are now in a dynamic route, this route was freshly rendered at request time. The data we're seeing is the cached fetch for post 20 in the data cache.
In our cache folder, we only find one file for post 20. So, we did cache post 20 and then reused it. When we do a hard refresh, the time stays the same. The fetch hits the data cache, finds a match that hasn't expired so Next serves from cache.
This is all as expected and should make sense.
Route segment option
Setting the route segment option revalidate means setting a default revalidation for the entire route. This default value will be inherited by any fetch that does not have an explicit revalidate or cache setting (fetch(url): the auto cache option). But, the route segment will not overwrite the value of fetches with an explicit revalidate or cache:
// inherits route revalidate
fetch(url);
// does not inherit route revalidate:
fetch(url, { cache: 'force-cache' });
fetch(url, { cache: 'no-store' });
fetch(url, { next: { revalidate: false | 0 | number } });
We write some more tests:
route -> { revalidate: number }
// app/revalidate/(route-revalidate)/number/page.tsx
export const revalidate = 100;
export default function Page() {
return (
<>
<Post postId={21} fetchOptions={{}} />
<Post postId={22} fetchOptions={{ cache: 'force-cache' }} />
<Post postId={23} fetchOptions={{ next: { revalidate: 50 } }} />
</>
);
}
We expect:
- This route to revalidate at 50 (lowest number) in build log -> ✅ passes
- Fetch 21 to be cached at 100 (inherits because no explicit cache setting) -> ✅ passes
- Fetch 22 to be cached at Infinity (no overwrite) -> ✅ +/- passes: not Infinity but 31536000 = 1 year (default max)
- Fetch 23 to be cached at 50 (no overwrite) -> ✅ passes
Note: to set "✅ passes" I verified the data cache files manually.
route -> { revalidate: false }
The route revalidate false sets a default of force-cache without an expiry date, so Infinity.
// app/revalidate/(route-revalidate)/false/page.tsx
export const revalidate = false; // cache Infinity
export default function Page() {
return (
<>
<Post postId={24} fetchOptions={{}} />
<Post postId={25} fetchOptions={{ cache: 'force-cache' }} />
<Post postId={26} fetchOptions={{ next: { revalidate: 50 } }} />
</>
);
}
We expect:
- This route to revalidate at 50 (lowest number) in build log -> ✅ passes
- Fetch 24 to be cached at Infinity (1 year) (inherits) -> ✅ passes
- Fetch 25 to be cached at Infinity (1 year) (no overwrite) -> ✅ passes
- Fetch 26 to be cached at 50 (no overwrite) -> ✅ passes
route -> { revalidate: 0 }
A route revalidate 0 is the equivalent of no-store:
// app/revalidate/(route-revalidate)/zero/page.tsx
export const revalidate = 0; // no-store
export default function Page() {
return (
<>
<Post postId={27} fetchOptions={{}} />
<Post postId={28} fetchOptions={{ cache: 'force-cache' }} />
<Post postId={29} fetchOptions={{ next: { revalidate: 50 } }} />
</>
);
}
We expect:
- This route to be dynamic -> ✅ passes
- Fetch 27, 28, 29 not to be in data cache (The page hasn't rendered yet) -> ✅ passes
After starting up the server and visiting the page, we expect:
- Fetch 27 not to be in data cache (inherits) -> ✅ passes
- Fetch 28 to be cached at Infinity (1 year) (no overwrite) -> ✅ passes
- Fetch 29 to be cached at 50 (no overwrite) -> ✅ passes
So, every test behaved as expected. Fetches with no explicit cache setting inherit the value from the route revalidate. Fetches that do have an explicit cache setting keep their setting.
Invalid
Note, there are absurd or invalid options too:
export const revalidate = 100;
export default async function Page() {
await fetch(url, { next: { revalidate: 1000 } }); // 1000
return <div>Hello world!</div>;
}
The route revalidation will trigger revalidation after 100 seconds. The route will be statically rerendered (ISR) but the fetch hasn't expired. So, Next will just reuse the cached data. And nothing changes. The newly rendered route will be the same as the previously rendered route. So, don't do this. This is an absurd config.
Another absurd example:
fetch(url, {
cache: 'no-store',
next: {
revalidate: false,
},
});
Setting no-store (don't cache) + revalidate: false (cache indefinitely) is ridiculous. eslint doesn't catch this but Next does. It throws a warning in dev mode: ⚠ Specified "cache: no-store" and "revalidate: false", only one should be specified. and ignores both.
Cache Invalidation Strategy: stale while revalidate
Time-based revalidation uses the cache invalidation strategy stale while revalidate. Let's say we have data that expired. User Bob makes a request for this data. Next reacts:
- Checks the
full route cache, finds a match and sees it's expired. - Serves
Bobthe current (stale) data. (this is thestalepart instale while revalidate) - In the background: (this is the
revalidatepart instale while revalidate)- If the route is static: fresh static render update (
full route cache(ISR)) + refetching stale data (updatedata cache). - If the route is dynamic: refetching stale data (update
data cache).
- If the route is static: fresh static render update (
The next user to make a request, will then receive 'fresh' data from cache.
So, in short. When a request hits stale/expired cache, Next will serve this cache and update in the background. Only the next request will receive fresh data. The advantage of using stale while revalidate is that requests are answered really fast (from cache). The disadvantage is that the first request will receive stale data. Isn't this bad? No. Use it when it doesn't matter. If receiving stale data does matter, you need to use a different caching strategy.
Testing SWR
It's time to actually test revalidation now. We use a simple example, trigger swr and look at the results.
// app/revalidate/time/swr/page.tsx
export default function Page() {
return (
<>
<Post postId={30} fetchOptions={{ next: { revalidate: 60 } }} />
<Post postId={31} fetchOptions={{ next: { revalidate: 600 } }} />
<Post postId={32} fetchOptions={{ cache: 'force-cache' }} />
</>
);
}
We expect:
[action]
next build
[Expect]
✅ pass: Route to be statically rendered and have expiry of 60s
✅ pass: Fetches 30 to be in data cache with revalidate 60
✅ pass: Fetches 31 to be in data cache with revalidate 600s
✅ pass: Fetches 32 to be in data cache with revalidate 1 year
✅ pass: Fetches 30-32 to have +/- same create date (headers.date)
[action]
take note of create date of fetches
next start
visit route /revalidate/time/swr after 2 minutes
[expect]
✅ pass: Stale behavior in browser: old time still displayed for all fetches
✅ pass: Revalidated in cache: new create date for fetch 30
✅ pass: Fetch 31 and 32 not to have changed (unchanged create date)
[action]
refresh page
[expect]
✅ pass: Time in browser for fetch 30 to have updated
[action]
Wait 10 minutes
refresh page
[expect]
✅ pass: Time in browser for fetch 30 and 31 to stay the same
✅ pass: Fetch 30 to have been updated in the background again: new create date
✅ pass: Fetch 31 to have been updated in the background: new create date
✅ pass: Fetch 32 not to have changed (unchanged create date)
[action]
refresh page
[expect]
✅ pass: Time in browser for fetch 30 and 31 updated
So, everything worked as expected, SWR confirmed.
Summary
Revalidation means updating stale cache. In time based revalidation, cache becomes stale when it expires. When cache expires, it doesn't automatically update. This only happens at request time. When a request finds expired cache, it will serve this stale data and update the cache in the background. This is called stale while revalidate and all time based revalidation uses this mechanic.
Cache expiry is set via route revalidate or fetch revalidate. These can influence each other so be careful. A good hint is to limit the number of options (if possible):
- Don't use
revalidate: false, useforce-cache. - Don't use
revalidate: 0, useno-store.
This will leave you with a limited number of combinations:
fetch(url); // auto: no data cache
fetch(url, { cache: 'no-store' }); // no data cache + dynamic routes
fetch(url, { cache: 'force-cache' }); // data cache, doesn't expire
fetch(url, { next: { revalidate: 60 } }); // data cache for X seconds
// route segment option
export const revalidate = 60;
Time based revalidation can target both full route cache and data cache. In static routes, the lowest revalidate number in the route (either fetch revalidate or route revalidate) determines the expiry date of the route in the full route cache.
If you want to support my writing, you can donate with paypal.

Top comments (0)