Skip to main content
友田 陽大
Frontend
Next.js
TypeScript
App Router
キャッシュ管理
トラブルシューティング
実体験

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
Reading time
10 min read
Author
友田 陽大
Share

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.

// 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");
}
// 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.

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.

LayerWhere it existsScopeInvalidation means
① Request Memoizationserver (within one request)during the same renderingauto-disappears at request end
② Data Cacheserver (persistent)across all usersrevalidateTag / revalidatePath
③ Full Route Cacheserver (at build/revalidation)across all userslinked to Data Cache expiry
④ Router Cachebrowser (each tab)that user's navigationrouter.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).

// 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.

// 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}`);
}
// 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:

"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.

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:

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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading