# The time I burned half a day after being told 'revalidateTag works sometimes and not others' — the pitfall of the Next.js App Router's 4-layer cache

> A real experience of stepping on the mysterious bug in production where the Next.js App Router's `revalidateTag` 'only works sometimes,' until I realized the cause was not the Data Cache but the client's Router Cache. A troubleshooting record organizing the responsibilities of the 4-layer cache and the invalidation means of each.

- Published: 2026-04-18
- Author: 友田 陽大
- Tags: Next.js, TypeScript, App Router, キャッシュ管理, トラブルシューティング, 実体験
- URL: https://tomodahinata.com/en/blog/revalidate-tag-nextjs-router-cache-trap
- Category: Frontend
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-16-app-router-cache-components-data-fetching

## Key points

- The true identity of revalidateTag working sometimes and not others is that what it can invalidate is only the Data Cache, and the Router Cache is a different thing.
- The Router Cache exists in each browser tab and keeps holding stale RSC for tens of seconds within the same tab.
- Solve it by calling router.refresh() before router.push() after the Server Action, making the client-side cache discarded.
- The App Router's cache has 4 layers; isolate in the order Router Cache → Data Cache → CDN and don't mix them in your head.
- unstable_cache is a separate vault invisible to revalidateTag unless you pass tags.

---

## The beginning: a Slack at 5 PM on Friday

> PM: "We're getting a complaint from the customer that 'sometimes it doesn't reflect in the list even after editing' — could you take a look?"

Friday evening, the kind of message you least want to hear.

The feature in question was an ordinary CRUD in the admin screen of a SaaS built with Next.js 15's App Router, where you **update an article with a Server Action → return to the list page and the latest is reflected.** The code was (to me at the time) by-the-textbook.

```tsx
// app/articles/actions.ts
"use server";
import { revalidateTag } from "next/cache";

export async function updateArticle(id: string, formData: FormData) {
  const title = String(formData.get("title") ?? "");
  await db.articles.update(id, { title });
  revalidateTag("articles"); // これで一覧が最新になる…はず
  redirect("/articles");
}
```

```tsx
// app/articles/page.tsx
export default async function ListPage() {
  const res = await fetch(`${process.env.API}/articles`, {
    next: { tags: ["articles"], revalidate: 300 },
  });
  const articles = await res.json();
  return <ArticleTable rows={articles} />;
}
```

Code that obviously "works." Locally (`next dev`), no matter how many times I tried, it **reflects 100%.**

Yes, **locally.**

---

## 17:30–18:00: trying to reproduce in production

I repeat the same operation on the production URL.

- 1st time: update → return to the list and it's **reflected**
- 2nd time: update a different article → return to the list and it's **still stale**
- 3rd time: re-update → reflected
- 4th time: close the tab and reopen → reflected

**It's reproducible, but not reliably so.**
This "**bug that only half-happens**" is the most troublesome, and the most trap-laden pattern.

I write just the situation to Slack for now:

> Me: Confirming reproduction in production. It doesn't entirely not happen, nor always happen — suspicious. I intend to get to the cause today; fixing may be early next week.

---

## 18:00–19:00: the first hypothesis (and a miss)

### Hypothesis ①: is the CDN cache stale?

Thinking "it's Vercel so basically no problem," I go look at the Response headers.

```
x-vercel-cache: MISS
age: 0
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
```

`MISS` every time. The CDN is innocent.

### Hypothesis ②: is the `fetch`'s Data Cache not updating?

Here I re-confirm `next/cache`'s behavior in my own way.
Call `revalidateTag("articles")` and the result of the `fetch` with `next: { tags: ["articles"] }` gets marked "stale," and **it should be re-fetched the next time that route is rendered.**

To directly confirm this, I had the list page's fetch result spit out with `console.log`.

```tsx
export default async function ListPage() {
  const res = await fetch(`${process.env.API}/articles`, {
    next: { tags: ["articles"], revalidate: 300 },
  });
  const articles = await res.json();
  console.log("[list] fetched at", new Date().toISOString(), "count=", articles.length);
  return <ArticleTable rows={articles} />;
}
```

Watching Vercel Logs, I repeat update → return to the list.

> `[list] fetched at 2026-04-10T09:14:03.181Z count=12`
> `[list] fetched at 2026-04-10T09:14:27.551Z count=13`  ← the new post was reflected
>
> (the next update operation)
>
> …no log appears.

It doesn't appear. **The RSC isn't being re-executed.** This isn't a Data Cache story. The request isn't even reaching the server in the first place.

Here, the possibility that "**the client-side cache is alive**" becomes strong.

---

## 19:00–20:00: the Router Cache, an alter ego

The App Router actually has **4 cache layers.** This is written in the official documentation too, but the names are confusing and easy to mix up.

| Layer | Where it exists | Scope | Invalidation means |
|---|---|---|---|
| ① Request Memoization | server (within one request) | during the same rendering | auto-disappears at request end |
| ② Data Cache | server (persistent) | across all users | **`revalidateTag` / `revalidatePath`** |
| ③ Full Route Cache | server (at build/revalidation) | across all users | linked to Data Cache expiry |
| ④ Router Cache | **browser (each tab)** | that user's navigation | **`router.refresh()` / navigation** |

What I'd been invalidating was `revalidateTag("articles")`, which is a tool that erases **only ② Data Cache.**
But the browser holds the snapshot of **④ Router Cache** from having just opened `/articles`, and for the returning user, it shows that first.

### Reproduction steps (this made it click)

1. The user opens `/articles` → the RSC payload enters the Router Cache
2. Navigate to `/articles/[id]/edit` and update → the Data Cache is cleared by `revalidateTag("articles")`
3. Return with `redirect("/articles")`
4. The Router Cache **still has the old RSC payload** (unrelated to the Data Cache expiry)
5. The browser displays it
6. It disappears once you cross a certain time (default is tens of seconds) or hard-reload

In other words, **when the time from the user editing to returning to the list is long it "looks reflected," and when it's short it's "still stale."** This was the true identity of "works sometimes and not others."

Once you get here, the log behavior I saw earlier (the RSC not being re-executed) is all explained too. **The browser decided the rendering itself before querying the server.**

---

## 20:00–20:30: trying the fix

### Fix 1: `router.refresh()` after the Server Action

You need to explicitly invalidate the Router Cache on the client side. A Server Action alone doesn't take care of the Router Cache (in some cases).

```tsx
// app/articles/[id]/edit/EditForm.tsx
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { updateArticle } from "../actions";

export function EditForm({ id }: { id: string }) {
  const router = useRouter();
  const [pending, start] = useTransition();

  return (
    <form
      action={(fd) => {
        start(async () => {
          await updateArticle(id, fd);
          // ビジネス目的：Router Cache（クライアント側キャッシュ）を捨てさせ、
          //           最新のData Cacheから引き直させる。これがないと、
          //           ユーザー体感で「反映されていない」と見える不具合が起きる。
          router.refresh();
          router.push("/articles");
        });
      }}
    >
      {/* フォーム本体 */}
      <button disabled={pending}>保存</button>
    </form>
  );
}
```

The point is **calling `router.refresh()` before `router.push()`.** In the reverse order, `refresh` may not reach the Router Cache reference after navigation, and the behavior becomes unstable.

Re-verify locally → it now reflects 100%. **20 minutes after shipping to production, the complaints stopped.**

### Fix 2: a design guideline as a permanent measure

Fixing it once isn't OK; I want it so that **anyone on the team who writes it doesn't step on the same trap.** So I brought the following "rules" into the project.

```ts
// lib/cache/revalidate.ts
// 目的：
//   App Router の 4 層キャッシュを「3行で正しく」無効化するための共通ヘルパ。
//   ・Server Action 側は tag の無効化責務だけを持つ
//   ・Client 側は router.refresh() の責務だけを持つ
//   呼び出し側で片方を忘れても、もう片方が補う設計にはしない（=忘れたら壊れる）。
//   代わりに、useOptimisticUpdate 経由の呼び出しを強制する ESLint ルールで守る。

import { revalidateTag, revalidatePath } from "next/cache";

// サーバー側：Data Cache 無効化
export async function invalidateArticles(): Promise<void> {
  // ビジネス目的：全ユーザーが編集操作の直後に最新版を見られるようにする
  revalidateTag("articles");
}

// サーバー側：特定ルートのFull Route Cacheまで消したい場合
export async function invalidateArticleDetail(id: string): Promise<void> {
  revalidatePath(`/articles/${id}`);
}
```

```tsx
// lib/cache/useRevalidatingAction.ts
"use client";
import { useRouter } from "next/navigation";
import { useTransition } from "react";

/**
 * 任意のServer Actionを呼び、成功後にRouter Cacheを無効化して目的地へ遷移する共通フック。
 * ビジネス目的：Router Cacheの消し忘れによる「反映されない問題」を構造的に防止。
 * 呼び出し元は「何をしたいか」だけを書けばよい。
 */
export function useRevalidatingAction<A extends unknown[], R>(
  action: (...args: A) => Promise<R>,
  opts?: { redirectTo?: string },
) {
  const router = useRouter();
  const [pending, start] = useTransition();

  const run = (...args: A) =>
    start(async () => {
      await action(...args);
      router.refresh(); // Router Cacheを捨てる
      if (opts?.redirectTo) router.push(opts.redirectTo);
    });

  return [run, pending] as const;
}
```

On the using side:

```tsx
"use client";
import { useRevalidatingAction } from "@/lib/cache/useRevalidatingAction";
import { updateArticle } from "./actions";

export function EditForm({ id }: { id: string }) {
  const [run, pending] = useRevalidatingAction(
    (fd: FormData) => updateArticle(id, fd),
    { redirectTo: "/articles" },
  );
  return (
    <form action={(fd) => run(fd)}>
      {/* … */}
      <button disabled={pending}>保存</button>
    </form>
  );
}
```

With this, **the accident of "forgetting to invalidate the Router Cache" itself disappears from the "writable paths" in the code.** The experience of stepping on this bug is best returned to the future by "designing so it doesn't break even if you forget," not "being careful not to forget."

---

## An additional trap I noticed the next day: `unstable_cache` is a separate world

Monday morning, I found a similar "doesn't reflect" case on a different screen, but this time `router.refresh()` doesn't fix it either.

The code in question was this.

```tsx
import { unstable_cache } from "next/cache";

const getRelated = unstable_cache(
  async (articleId: string) => db.articles.findRelated(articleId),
  ["related-articles"], // key parts
  { revalidate: 3600 /* tagsは書いていなかった！ */ },
);
```

**`unstable_cache` is invisible to `revalidateTag` unless you specify tags.** It's a design where `revalidate: 3600` keeps it stale for 1 hour.

The fix is just passing tags:

```tsx
const getRelated = unstable_cache(
  async (articleId: string) => db.articles.findRelated(articleId),
  ["related-articles"],
  {
    revalidate: 3600,
    tags: ["articles"], // ビジネス目的：記事更新時に関連記事もまとめて失効させる
  },
);
```

It's plain, but I think this is also a point many people get stuck on. **The moment you wrap it with `unstable_cache`, it's a separate vault from the Data Cache.** Without handing over that vault's key, it can't be opened from anywhere.

---

## Reflection: why is this trap easy to step on

The biggest point of regret in this bug is that **I implicitly assumed "the cache was erased" = "it looks new to the user."**

The correct understanding can be organized as follows.

- Erasing the server-side cache (Data Cache / Full Route Cache): **prepare to "rebuild it new the next time someone comes to look."**
- Erasing the client-side cache (Router Cache): **the act of "making this browser, now, come to look anew."**

These are **different roles**, so just calling `revalidateTag` on the Server Action side may mean the current user on the browser side is still seeing the old scenery — and this is by spec.

**"Sometimes it doesn't reflect" is usually the work of "a different, crossed-over cache layer."** From now on, if I get a similar report, I decided to isolate without hesitation in the order Router Cache → Data Cache → CDN → the browser's memory cache.

---

## Lessons (a reminder to myself)

1. **The App Router's cache has 4 layers.** The means to erase each differs too. Don't mix them in your head.
2. **`revalidateTag`'s coverage is up to the Data Cache (+ the Full Route Cache as its extension).** The client's Router Cache is `router.refresh()`'s domain.
3. **In a flow that navigates after a Server Action, call `router.refresh()` first.** Wrap it in a common helper and prevent the write-omission by mechanism.
4. **`unstable_cache` is a separate vault.** To erase it with `revalidateTag`, always stick the same tag into that cache too.
5. **"Sometimes ~" is a treasure trove of hypotheses.** For a bug that doesn't happen deterministically, first suspect whether "a time-dependent separate layer" is present.

### References

- The `Caching` chapter of the Next.js official documentation (read the "Router Cache" section and everything in this story is written there. It sinks in deep when you re-read it later…)
- Each item of `revalidateTag` / `revalidatePath` / `unstable_cache` in the Next.js official documentation
- The implementation I used as of spring 2026 was Next.js 15 family. **In Next.js 16 the Router Cache's default behavior has changed**, so on upgrade I recommend re-reading it along with the staleTimes setting

---

I burned half a day, but thanks to it the App Router's cache map went into my head. **I believe that leaving a note of the place you tripped is the best gift to the next someone who walks the same place.** May it be a clue for someone stuck at the same spot.
