Type something to search...
Next.js How revalidation works

Next.js How revalidation works

Most Next.js developers interact with revalidation at a very shallow level: you call revalidateTag() after a mutation, or you set a cacheLife profile, and content updates. That's the right amount of detail for building an application. It is the wrong amount of detail the moment something goes wrong in production — stale content appearing after a purge, one server behind a load balancer serving old data while another serves fresh data, or a client-side navigation rendering something that doesn't match what a fresh page load would show.

Those problems all live below the API surface, in the machinery that decides what "revalidated" actually means, how invalidation signals propagate, and what happens when the underlying storage can't guarantee consistency. If you're running a single Next.js instance on a platform that already handles this for you (Vercel, most managed hosts), you'll rarely need this. If you're self-hosting behind multiple instances, writing a custom cache handler, or debugging why your CDN is serving mismatched content, this is required reading.

This article is explicitly aimed at that second audience — platform engineers, people writing custom cache handlers, and anyone who has hit a revalidation bug that the basic caching docs didn't explain. If you haven't yet read the higher-level Caching or Revalidating material and just want to know how to call revalidateTag, start there instead — this piece assumes you already know the basic API and want to understand what happens after you call it.

Two Kinds of Revalidation, One Underlying Model

Next.js exposes two conceptually different ways to invalidate cached content, and it's worth being precise about the distinction because they fail differently.

Time-based revalidation is a stale-while-revalidate pattern. A cache entry has an age, and once that age crosses a threshold — set via a cacheLife profile or the older revalidate option — the next request that hits it triggers a background regeneration. Crucially, the stale entry is still served to that triggering request and to any concurrent requests while the regeneration is in flight. Nobody waits on a rebuild. The tradeoff is that "revalidated" doesn't mean "instant" — there's an unavoidable window where content is known to be stale but still being served, because the alternative (blocking every request on a rebuild) would be worse for availability.

On-demand revalidation is the opposite: an explicit signal, either revalidateTag() or revalidatePath(), that says "this content is invalid right now." The next request after that call triggers a fresh render — no stale-while-revalidate grace period. This is what you use after a mutation: a user publishes a post, you call revalidateTag('posts'), and the next visitor to any page tagged posts gets freshly rendered content.

Both mechanisms ultimately do the same thing under the hood — they mark cache entries as invalid and trigger a fresh render on next access — but the trigger is different: elapsed time in one case, an explicit call in the other. Understanding that they share the same invalidation machinery matters later, because it means everything below about tag propagation and multi-instance coordination applies equally to both.

One detail that's easy to miss: this whole model isn't exclusive to the App Router. Pages Router routes that produce ISR/prerender output go through the same on-demand revalidation path — res.revalidate() and the x-prerender-revalidate header flow are still fully supported, and they use the same cache handler underneath. The one carve-out is Pages Router routes that are automatically statically optimized (pure static pages with no data dependency) — those aren't revalidated on demand because there's nothing dynamic in them to begin with.

// app/actions/publish-post.ts
"use server";

import { revalidateTag } from "next/cache";

export async function publishPost(postId: string) {
  await db.post.update({ where: { id: postId }, data: { published: true } });

  // On-demand: the next request to anything tagged "posts" gets a fresh render
  revalidateTag("posts", "max");
}

The 'max' argument you'll see in revalidateTag calls in recent Next.js versions controls the profile of the revalidation — essentially how aggressively it propagates — but the mechanics described in this article apply regardless of which profile you use.

What "Revalidated" Actually Regenerates

Here's a detail that trips people up when they start reasoning about caching at the infrastructure level: when a route gets revalidated, Next.js doesn't just regenerate an HTML string. It regenerates two artifacts from the same render pass — the HTML response you'd get from a full page load, and the RSC (React Server Components) payload used for client-side navigations — and stores them together as a single cache entry.

This pairing exists for a specific reason. When a user clicks a <Link> inside your app, Next.js doesn't refetch and re-render a full HTML document; it fetches the RSC payload and reconciles it into the existing page. If your infrastructure ever serves an HTML response from one render and an RSC payload from a different render — say, because a CDN cached them separately with different TTLs — a user could get a full page load showing one version of the data and then click a link and see stale or inconsistent data flash in during the client-side transition. It's a subtle bug class because it's invisible on hard reloads and only shows up during SPA-style navigation, which makes it very easy to ship without noticing in local development.

The practical mitigation, if you're building or configuring caching infrastructure in front of Next.js, is: cache HTML and RSC responses together, with the same TTL and the same invalidation policy, and respect the Vary header Next.js sets to distinguish them. If you're using a CDN, this is exactly the kind of thing that's easy to get subtly wrong when someone configures cache rules by content-type instead of by the semantics Next.js expects — see the CDN caching guide for the specifics of what headers to key on.

There's a second, related failure mode that's worth flagging separately because it looks similar but has a completely different cause: cross-deployment skew. During a rolling deployment, some fraction of your users have already loaded a client bundle built from deploy A, while your servers are gradually cutting over to deploy B. If a client built against A makes a request that lands on a B server, the RSC payload it gets back may not match what the A-era client expects, again producing visible inconsistency during navigation. Next.js's answer to this is the deploymentId config option — when the client detects that the server's deployment ID doesn't match what it was built with, it forces a hard navigation instead of a soft one, trading a full page reload for correctness. If you deploy behind a load balancer that doesn't do atomic cutovers, setting deploymentId explicitly is worth doing even if you never touch caching directly.

The Tag System: Explicit Tags vs. Soft Tags

Everything about targeted invalidation in Next.js runs through a tag system, and there are two categories of tags with genuinely different behavior.

Explicit tags are ones you write yourself, either by calling cacheTag() inside a use cache function, or by passing next: { tags: [...] } to a fetch() call. These are the tags you invalidate directly:

// lib/data.ts
import { cacheTag } from "next/cache";

async function getPost(slug: string) {
  "use cache";
  cacheTag("posts", `post-${slug}`);

  return db.post.findUnique({ where: { slug } });
}
// somewhere after a mutation
import { revalidateTag } from "next/cache";

revalidateTag(`post-${slug}`, "max");

Calling revalidateTag('post-hello-world', 'max') invalidates every cache entry anywhere in your application that was tagged with that exact string. That's the mechanism you already know from the higher-level docs.

Soft tags are the more interesting half, because Next.js generates them automatically and most developers never interact with them directly — they're the reason revalidatePath() works at all. Every route gets a set of soft tags derived from its position in the file-system route tree, prefixed with _N_T_. For a route like /blog/hello, Next.js generates something like _N_T_/layout, _N_T_/blog/layout, _N_T_/blog/hello/layout, and _N_T_/blog/hello — one tag per layout segment in the path, plus a tag for the leaf route itself.

This is what lets revalidatePath('/blog/hello') invalidate the right set of cache entries without you ever having called cacheTag yourself: it invalidates the leaf tag and every ancestor layout tag along the way. It's effectively path-based invalidation implemented on top of the same tag machinery as explicit tags, rather than being a separate system.

If you're writing a custom cache handler (more on that below), this distinction becomes directly relevant: soft tags are passed into your handler's get() method as a softTags parameter, and your handler is responsible for checking whether any of them have been invalidated more recently than the cache entry's own timestamp. The companion getExpiration() method returns the most recent revalidation timestamp across the tags you give it — or 0 if none were revalidated — and can also return Infinity as a signal that you'd rather have the soft tags checked inside get() instead. Getting this contract right is the difference between a cache handler that correctly serves fresh content after a path revalidation and one that silently keeps serving stale pages because it only ever checked explicit tags.

Why a Single Instance "Just Works" and Multiple Instances Don't

This is the part of the model that actually causes production incidents, and it's almost never mentioned until you've already been bitten by it.

On a single Next.js instance, the default file-system cache handler keeps tag invalidation state in memory and writes cache entries to local disk. Both operations are effectively atomic from the process's point of view, so calling revalidateTag() and then immediately requesting the affected route always produces fresh content. There is no propagation delay because there is nowhere to propagate to.

The moment you run more than one instance behind a load balancer — which is the normal shape of any self-hosted deployment with more than trivial traffic — this guarantee disappears by default. revalidateTag() only affects the instance that received the call. Every other instance has no idea an invalidation happened and will happily keep serving its locally cached (now stale) copy until it independently decides to revalidate, which might be a long time if you're relying on long cacheLife windows.

Concretely: a user submits a form that calls a Server Action, which calls revalidateTag('posts'). The request that ran that Server Action gets a fresh render on that instance. But the next visitor, load-balanced to a different instance, keeps seeing the old data — potentially for a while — because nothing told that instance anything changed.

Next.js's cache handler API gives you exactly two hooks to close this gap, and understanding what each is for is the whole trick:

  • updateTags() fires when revalidateTag() is called. Your implementation's job is to write that invalidation event somewhere every instance can see it — Redis, a database row, anything shared.
  • refreshTags() fires periodically, and always immediately before a new request starts processing. Your implementation's job is to read from that shared store and update the instance's local view of which tags are currently invalid.

Here's a minimal shape of what that looks like with Redis, simplified for clarity (see the cacheHandlers config reference for the exact interface Next.js expects you to implement):

// cache-handler.ts
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

export default class RedisCacheHandler {
  async updateTags(tags: string[]) {
    const now = Date.now();
    await redis.mset(
      ...tags.flatMap((tag) => [`revalidated:${tag}`, String(now)]),
    );
  }

  async refreshTags() {
    // Pull the latest known invalidation timestamps into local memory
    // so getExpiration() below can compare against them without
    // hitting Redis on every single request.
    try {
      await this.syncLocalTagState();
    } catch {
      // Swallowing this is intentional and important — see below.
    }
  }

  async getExpiration(...tags: string[]): Promise<number> {
    const timestamps = tags.map((t) => this.localTagState.get(t) ?? 0);
    return Math.max(0, ...timestamps);
  }

  // get(), set(), and the rest of the interface omitted for brevity
}

The comment about swallowing errors in refreshTags() is not a stylistic aside — it's specified behavior you need to implement correctly. If refreshTags() throws, that exception propagates as a request failure. Your Redis connection blipping should degrade your app to "serving slightly stale content," not "returning 500s to every user." Catching the error and falling back to the last known local tag state is the correct behavior, and it's exactly the kind of detail that's obvious once you've read it and easy to get backwards if you're implementing this from first principles under deadline pressure.

The broader pattern for multi-instance deployments, if you're setting this up yourself rather than relying on a platform that already does it:

  1. Pick a shared store all instances can reach — Redis, DynamoDB, or even a simple internal HTTP endpoint backed by a database.
  2. Implement updateTags() to write invalidation timestamps there.
  3. Implement refreshTags() to read them back, with errors caught and swallowed so a store outage degrades gracefully instead of failing requests.
  4. If you want the mismatch window as small as possible, also store the actual cache entries (HTML + RSC payload, together) in that shared store rather than per-instance local disk — this isn't strictly required for correctness, but it shrinks the time during which different instances can disagree about what's cached.

Graceful Degradation Is the Actual Design Goal

If there's one mental model shift worth taking away from all of this, it's that Next.js's revalidation system is explicitly designed to prioritize availability over strict consistency. That's not a compromise forced by implementation limitations — it's the stated design intent, and it shows up in how every failure mode is handled:

  • If a cache write fails, the user still gets served — the response was already generated and sent, the write is a side effect. You just lose that specific cache entry, and the next request pays the cost of a fresh render. Nobody sees an error because of this.
  • If a cache read fails inside a custom handler, the contract is that you return undefined — the defined "cache miss" signal — rather than letting an exception escape. Next.js then falls back to rendering fresh. Throwing instead of returning undefined is a real bug: it turns a cache problem into a render error and takes down a request that should have degraded gracefully instead.
  • Multi-instance staleness, as covered above, degrades to "some users see slightly old content for a window of time," not "the app breaks."
  • Cross-deployment skew degrades to "a hard navigation instead of a soft one" when deploymentId is configured, rather than silently serving mismatched payloads.

The pattern across all four is the same: when the system can't guarantee it has the freshest possible content, it serves something rather than serving nothing. If you're building a custom cache handler, internalizing this priority is more useful than memorizing the specific method signatures, because it tells you how to handle every edge case you didn't anticipate — default to serving stale or falling back to a fresh render, never to an error.

When You Actually Need to Care About Any of This

To be direct about the practical calculus: if you deploy to a platform that manages this for you, you can skip everything below the tag system section and never think about it again — that's precisely the point of using a managed platform. Where this becomes unavoidable reading is:

  • You're self-hosting behind more than one Next.js instance (a Docker Swarm/Kubernetes deployment behind a load balancer is the common case).
  • You're writing a custom cache handler to back your cache with Redis, a CDN, or some other shared store instead of the local filesystem.
  • You're debugging a report of "stale content that won't go away" and need to know whether the bug is in your tags, your multi-instance coordination, or your CDN configuration.
  • You're building a platform or adapter that other people will deploy Next.js applications onto, in which case this entire model is effectively the spec you need to implement against.

If none of those describe you today, the shallow API-level knowledge — call revalidateTag, set a sensible cacheLife — is genuinely sufficient. Keep this article bookmarked for the day one of those bullets becomes true.

Key Takeaways

ConceptWhat it means in practice
Time-based vs. on-demand revalidationSame underlying invalidation machinery, different trigger — elapsed age vs. an explicit revalidateTag/revalidatePath call
HTML + RSC pairingBoth artifacts come from one render and must be cached/invalidated together, or client-side navigations can show mismatched content
deploymentIdForces a hard navigation when a client detects it's talking to a different deployment, preventing cross-deploy payload mismatches
Explicit tagsSet via cacheTag() or fetch's next.tags — invalidated directly by name with revalidateTag()
Soft tags (_N_T_...)Auto-generated per route segment; the mechanism that makes revalidatePath() work without any manual tagging
Single instanceWorks correctly by default — no coordination needed, memory + local disk are enough
Multiple instancesrevalidateTag() is local to the instance that received it by default — other instances need updateTags()/refreshTags() to learn about it
refreshTags() errorsMust be caught, not thrown — an unhandled error here turns a cache hiccup into a failed request
Read failures in a custom handlerReturn undefined to signal a miss; throwing turns a cache problem into a render error
Overall design priorityAvailability over strict consistency — every failure mode degrades to "serve something imperfect," never to an outage

Revalidation in Next.js is designed so that, most of the time, you never need to know any of this — you call a function, content updates, done. But the moment you're running more than one server or writing infrastructure that other services depend on, the gap between "the API works" and "I understand why it works" is exactly the gap that produces 2am incidents. Read the cache handler reference alongside this if you're implementing one, and treat "serve stale, never serve broken" as the north star for any edge case the docs don't explicitly cover.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper