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.
| 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)
- The user opens
/articles→ the RSC payload enters the Router Cache - Navigate to
/articles/[id]/editand update → the Data Cache is cleared byrevalidateTag("articles") - Return with
redirect("/articles") - The Router Cache still has the old RSC payload (unrelated to the Data Cache expiry)
- The browser displays it
- 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)
- The App Router's cache has 4 layers. The means to erase each differs too. Don't mix them in your head.
revalidateTag's coverage is up to the Data Cache (+ the Full Route Cache as its extension). The client's Router Cache isrouter.refresh()'s domain.- 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. unstable_cacheis a separate vault. To erase it withrevalidateTag, always stick the same tag into that cache too.- "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
Cachingchapter 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_cachein 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.