Skip to content
intermediate Phase 5 · Data Fetching and Caching

Revalidation with Time and Tags

Refresh cached data on a schedule or on demand.

45m
0 problems
Topic Progress 0%

Time-Based Revalidation: Setting revalidate: 60 and What Actually Refreshes

A pricing page can be sixty seconds old and nobody notices; a checkout total cannot. Time-based revalidation is how you say "this data may be reused for N seconds, then regenerate" — and the interesting part is what happens to the visitor who arrives during the refresh.

There are two places to put the number, and they do not mean the same thing:

// 1) per fetch — this entry of the data cache refreshes after 60 s
const res = await fetch('/api/products', { next: { revalidate: 60 } });

// 2) route segment config — every fetch in this segment defaults to 60 s
export const revalidate = 60;
export default async function ProductsPage() { ... }

The per-fetch value wins for that call; the segment config is the default for everything below it that does not set its own. The minimum unit is one second — sub-second values are not a supported way to build a live feed, and a route that truly must be fresh is better served by cache: 'no-store' than by revalidate: 1, which still reuses a just-rendered result for a moment.

Now the behavior that interviewers actually ask about. When an entry's window expires, the next request does not wait: it is served the stale copy while regeneration happens in the background, and requests after the regeneration get the new copy. That is stale-while-revalidate, and it has a concrete consequence — after a deploy or a data change, the first visitor post-expiry sees old content, and the second sees fresh content. Your acceptance test for "is revalidation working?" must therefore make two requests, not one.

curl -s localhost:3000/prices | head -5    # stale copy, triggers regeneration
curl -s localhost:3000/prices | head -5    # fresh copy

The clock is per entry, not global: two fetches with revalidate: 60 started ten minutes apart expire ten minutes apart. Nothing in the system polls your API on a timer — regeneration is demand-driven. A page nobody visits never revalidates, which is a feature (no idle traffic) and a trap (an entry older than its window for a low-traffic path still serves stale on the next hit, then refreshes behind it).

What the number applies to also matters. revalidate: 60 on a fetch controls that cached response; the route's own HTML follows from the inputs that produced it. Give every fetch in the page revalidate: 60 and the prerendered page can be regenerated on the same cadence; leave one fetch with a longer window and the page's regeneration still waits on the shared entries — the effective freshness of a route is the tightest of its inputs, not the segment config alone.

Time-based revalidation is the simple contract and the right default when nobody can tell you exactly when the source data changes. The moment someone can — "publish updates the post" — you want the source of truth to drive invalidation instead of a timer, which is the tags mechanism introduced for fetch entries in Request Memoization and the Data Cache.

Two practical notes before moving on. First, the segment-level export const revalidate = 60 applies to fetches made during that segment's render — it is not a page-refresh interval, and it does not override a per-fetch value that explicitly says cache: 'no-store' (uncached input cannot be scheduled for refresh; there is no entry to expire). Second, time-based revalidation and ISR are the same mechanism seen from two angles: the route's prerendered HTML is regenerated on the same stale-while-revalidate rules as the fetch entries underneath it, which is why a build that printed ○ /prices (ISR: 60s) behaves exactly like the fetch-level number you set.

Tagging Reads and Invalidating Them with revalidateTag from Anywhere

A timer guesses. A tag knows. If your CMS webhook, your admin action, or your queue consumer knows the moment a post changes, it should be the thing that invalidates — and tags are the handle you attach to a cached read so that moment can reach it.

Step one: mark the reads. Tags belong to individual fetches (or to cache wrappers around other data sources):

// app/blog/[slug]/page.tsx
const post = await fetch(`https://cms/posts/${slug}`, {
  next: { tags: ['posts', `post:${slug}`] },
});

Tags are free-form strings; use a coarse one (posts) for "everything in this collection" and a fine one (post:42) for a single record. Multiple tags per fetch are normal and let you invalidate from either angle.

Step two: fire the invalidation from anywhere that runs on the server — a server action, a route handler, revalidatePath's sibling in a webhook:

// app/api/cms/route.ts  (CMS webhook)
import { revalidateTag } from 'next/cache';

export async function POST(req: Request) {
  const { slug } = await req.json();
  revalidateTag('posts');            // every fetch tagged 'posts' is now stale
  revalidateTag(`post:${slug}`);     // or just this record
  return Response.json({ revalidated: true });
}

The mechanics worth knowing precisely. revalidateTag marks matching cache entries stale and re-renders any routes that used them; the next request for those routes performs the regeneration. Calling it during a server action or route handler also refreshes the client's Router Cache for the affected paths, so a UI that just mutated data updates without a manual reload. In a long-lived process, tags have ordering semantics (an invalidation issued after a read marks later re-uses stale), which is what makes concurrent edits converge instead of a slower request overwriting a newer one.

What tags give you that time cannot:

Concern revalidate: 60 revalidateTag('posts')
Freshness after a change up to 60 s late immediate, at the moment you know
Traffic to your API at least once per window zero while nothing changes
Scope per fetch entry every entry carrying the tag
Works for non-fetch data no yes, via a tagged cache wrapper
Requires a trigger no yes — server code must call it

A real deployment pattern combines both: tag for correctness, short time window as the safety net for missed webhooks. If the webhook silently fails, the revalidate: 300 on the same fetch bounds how wrong you can be — belt and braces, and the two mechanisms do not conflict.

Watch the footguns. Invalidating posts after editing one post is coarse but safe; invalidating a tag nobody ever attached does nothing and looks like it worked (no error). And tags on a fetch whose response is per-user must never be shared across users — the tag invalidates the entry, it does not scope who may read it; authorization still belongs in the request, not in the cache key's absence. When the granularity you need is "this URL", not "this collection", the path-based API is the better tool.

revalidatePath, On-Demand Refreshes, and Choosing Between the Two

revalidatePath targets a route instead of a data entry. That distinction decides which API you reach for: tags live on cached reads, paths live on rendered URLs.

import { revalidatePath } from 'next/cache';

// one concrete URL
revalidatePath('/products/42');

// the whole dynamic segment — every /products/* page re-renders
revalidatePath('/products/[slug]', 'page');

// every route that this layout wraps (its children too)
revalidatePath('/dashboard', 'layout');

The three-argument forms are what people get wrong. revalidatePath('/blog') with no second argument invalidates that page. Add 'page' with a dynamic pattern ('/blog/[slug]') to hit the pattern rather than a literal path. Add 'layout' to invalidate the layout and every route beneath it — a much larger blast radius, which is exactly what you want after changing navigation data, and exactly what you do not want after editing one post.

Where it is allowed mirrors the rest of the server toolkit: server actions, route handlers, and server-side code — not a client component, not a useEffect. Inside a server action it composes naturally with the form flow:

'use server';
export async function publishPost(slug: string) {
  await db.post.update({ where: { slug }, data: { published: true } });
  revalidatePath('/blog');
  revalidatePath(`/blog/${slug}`);
}

Two APIs compared, so the choice is mechanical:

revalidateTag revalidatePath
Keyed by label on cached reads rendered URL / pattern / layout
Best when many routes share one data source one route's output changed
Granularity per record with fine tags per URL, segment, or whole subtree
Risk if misused misses entries not tagged 'layout' re-renders everything below

Teams usually standardize: invalidate data with tags, invalidate a URL when data lives outside the tagged system (a filesystem read, a third-party SDK you cannot wrap), and use path invalidation for structural changes — a rename, a sort order, a navigation item — where "what changed" is a URL, not a record.

One more lever, distinct from both: router.refresh() re-runs the current route's server components while preserving client state (an open drawer, typed form input). After a mutation inside a client component you will often see router.refresh() alongside the action — the server re-renders with fresh data, the UI does not unmount. Compare it to router.push: push navigates (new entry, remount), refresh re-renders in place (same URL, no entry). If invalidation is "tell the server its cache is wrong", refresh is "re-ask the server now, whatever it thinks".

The interview answer that lands: name the two keys (tag = data, path = route), state that both are called from server contexts after the mutation, describe the stale-while-revalidate behavior of a time window, and admit that the safest production setup is tags plus a bounded time fallback. That is a complete mental model, not a memorized API list.

One operational habit worth having: make invalidation observable. Return { revalidated: true, now: Date.now() } from the webhook, log the tag or path you passed, and — for anything load-bearing — follow the call with a request to the affected route and assert the response changed. Invalidation is invisible when it works and invisible when it silently misses (a typo'd tag invalidates nothing without erroring), so the only way to trust it is a check that fails loudly when the content did not move.

Quiz

1. A page uses fetch with next: { revalidate: 60 }. A visitor arrives 61 seconds after the last regeneration. What do they see?

Question 1 options

2. Three routes render the same CMS fetch tagged ['posts']. Where do you call invalidation after a CMS publish?

Question 2 options

3. After a navigation setting changes, every page under /dashboard should re-render. What is the correct call?

Question 3 options

4. A client component finishes a mutation and wants the server-rendered list re-run without unmounting an open drawer. What do you call?

Question 4 options

Flashcards

Question

How does time-based revalidation behave when an entry expires?

Answer

Stale-while-revalidate, demand-driven: the next request receives the stale copy while regeneration runs in the background, and subsequent requests get fresh data. No timer polls the API; idle paths never revalidate. Minimum granularity is 1 second.

Question

How do you invalidate cached data by tag, and from where?

Answer

Attach tags with next: { tags: ['posts', 'post:42'] } on the fetch (or a tagged cache wrapper), then call revalidateTag('posts') from a server action, route handler, or other server code. Every entry with that tag goes stale and routes that used it re-render on the next request.

Question

revalidatePath: the three call shapes and their blast radius?

Answer

revalidatePath('/products/42') — that exact URL. revalidatePath('/products/[slug]', 'page') — the dynamic pattern's pages. revalidatePath('/dashboard', 'layout') — the layout and everything beneath it (largest radius). Server contexts only.

Question

When do you choose tags over paths, or time over both?

Answer

Tags when many routes share one data source (keyed by what changed); paths when the key is a URL — structural changes or data you cannot tag (filesystem, third-party SDK). Time-based revalidate is the safety net when no trigger exists, and as a bounded fallback when a webhook might be missed.

Question

router.refresh() vs router.push() after a mutation?

Answer

refresh re-runs server components for the current URL with no history entry and no unmount, so client state (open drawer, typed input) survives — the standard post-mutation call. push navigates: new entry, leaf remount, layouts persist. Neither invalidates the server cache by itself; pair refresh with revalidateTag/revalidatePath in the action.

Revision Notes

Key Takeaways

  • 1. revalidate: N is a stale-while-revalidate window, not a polling schedule — regeneration happens on the next request after expiry
  • 2. Tags key on the data source so one call invalidates every route that read it; paths key on URLs, with 'layout' having subtree-wide blast radius
  • 3. Both APIs run only in server contexts — actions, route handlers — and never from a client component directly
  • 4. A bounded time revalidate under a tag is a cheap safety net for missed webhooks; the two mechanisms compose
  • 5. router.refresh() is the client half of the loop: re-render server components without unmounting client state

Interview Tips

  • • "What happens when revalidate: 60 expires?" — stale copy served immediately, regeneration in the background, later requests fresh; mention demand-driven and that verifying needs two requests
  • • "Tag or path invalidation?" — tag when many routes share one source and you know what changed; path when the key is a URL or the data can't be tagged; explain the 'layout' scope argument
  • • "How do you make the UI show fresh data after a mutation?" — action calls revalidateTag/revalidatePath, then the client calls router.refresh() or the action's revalidation already refreshes Router Cache
  • • "What if the CMS webhook never fires?" — bounded time fallback on the same fetch; that admission of failure modes reads as production experience

Cheat Sheet

Revalidation: Time, Tags, Paths

Time-based:

fetch(url, { next: { revalidate: 60 } });  // per fetch (wins)
export const revalidate = 60;              // route segment default
  • Minimum 1 s; expiry is stale-while-revalidate and demand-driven (no polling)
  • First request after expiry: stale copy + background regeneration → test with two requests
  • Route freshness = tightest of its inputs, not the segment config alone

Tag-based:

const post = await fetch(url, { next: { tags: ['posts', `post:${id}`] } });
// in a server action / route handler / webhook:
revalidateTag('posts');

Tag on the cached read (fetch or tagged cache wrapper), invalidate from server code. Missed tags fail silently — nothing is tagged, nothing goes stale.

Path-based:

revalidatePath('/products/42');                  // exact URL
revalidatePath('/products/[slug]', 'page');      // pattern's pages
revalidatePath('/dashboard', 'layout');          // layout + all children

Choosing: tag = what changed (data) · path = which URL (structure, untaggable sources) · time = no reliable trigger, or fallback under tags.

Client side: router.refresh() re-runs server components for the current route, keeps client state, adds no history entry (router.push does the opposite).

Continue Learning