DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on

Time based revalidation in Next

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:

  1. Set it on the route using a Route Segment option:
export const revalidate = false | 0 | number;
Enter fullscreen mode Exit fullscreen mode
  1. Set it on an individual fetch, using the options object:
fetch(url, { next: { revalidate: false | 0 | number } });
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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:

  1. Next checks the full route cache for this route, notices it's expired and statically renders the route again.
  2. 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
  3. Next finishes rendering the route and saves it in cache (ISR).

At 200 + 1 seconds, a new request.

  1. Next checks the full route cache for this route, notices it's expired and statically renders the route again.
  2. 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
  3. Next finishes rendering the route and saves it in cache (ISR).

At 300 + 1 seconds, a new request.

  1. Next checks the full route cache for this route, notices it's expired and statically renders the route again.
  2. 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
  3. Next finishes 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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 } }} />;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
// ...
Enter fullscreen mode Exit fullscreen mode

We run next start and visit this route: /revalidate/time/static:

time based revalidation

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 } }} />;
}
Enter fullscreen mode Exit fullscreen mode

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 } });
Enter fullscreen mode Exit fullscreen mode

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 } }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We expect:

  1. This route to revalidate at 50 (lowest number) in build log -> ✅ passes
  2. Fetch 21 to be cached at 100 (inherits because no explicit cache setting) -> ✅ passes
  3. Fetch 22 to be cached at Infinity (no overwrite) -> ✅ +/- passes: not Infinity but 31536000 = 1 year (default max)
  4. 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 } }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We expect:

  1. This route to revalidate at 50 (lowest number) in build log -> ✅ passes
  2. Fetch 24 to be cached at Infinity (1 year) (inherits) -> ✅ passes
  3. Fetch 25 to be cached at Infinity (1 year) (no overwrite) -> ✅ passes
  4. 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 } }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We expect:

  1. This route to be dynamic -> ✅ passes
  2. 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:

  1. Fetch 27 not to be in data cache (inherits) -> ✅ passes
  2. Fetch 28 to be cached at Infinity (1 year) (no overwrite) -> ✅ passes
  3. 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>;
}
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Checks the full route cache, finds a match and sees it's expired.
  2. Serves Bob the current (stale) data. (this is the stale part in stale while revalidate)
  3. In the background: (this is the revalidate part in stale while revalidate)
    • If the route is static: fresh static render update (full route cache (ISR)) + refetching stale data (update data cache).
    • If the route is dynamic: refetching stale data (update data cache).

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' }} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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, use force-cache.
  • Don't use revalidate: 0, use no-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;
Enter fullscreen mode Exit fullscreen mode

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)