Next.js Cache 与 TanStack Query 的关系
Next.js Cache 与 TanStack Query 都处理缓存身份、freshness 与 invalidation,但前者主要优化服务器/RSC 读取与渲染复用,后者管理浏览器中的 server-state projection 与交互期同步。
[!info] related notes
- 所属 MOC:
- 相关概念: nextjs-cache-life、nextjs-tag-based-revalidation、tanstack-query-cache-invalidation-patterns
- 易混淆概念:
- 相关资源:
Next.js Cache 与 TanStack Query 的关系
覆盖范围
Next.js Cache 与 TanStack Query 看起来都在做:
缓存
fresh / stale
失效
重新获取
mutation 后同步
所以很容易产生直觉:
“它们基本一样,只是 API 名字不同。”
这个类比在设计思想上有一部分是对的,但在运行层、共享范围、生命周期和 mutation 语义上差异很大。
最短结论:
Next.js Cache
→ server-side / RSC / rendering projection
TanStack Query
→ browser-side server-state projection
它们可以同时存在,而且经常应该同时存在。
共同的底层思想
两者确实共享几条非常重要的缓存原则。
1. Cache 不是 Truth
DB / Owning Service
↓
authoritative truth
Next.js Cache
↓
server projection
TanStack Query Cache
↓
client projection
冲突时,最终都必须向 authoritative source 收敛。
2. 缓存必须有身份
TanStack Query:
queryKey: ["goal", goalId]
Next.js:
cached function + arguments
+
cacheTag("goal:123") // invalidation identity
两者都在解决:
我到底在缓存哪一份数据?
3. Freshness 与 Invalidation 是不同概念
两边都有:
按时间变旧
+
业务事件主动失效
这比“固定 TTL 以后全部重拉”更精确。
最大区别一:缓存运行在哪一层
Next.js Cache
主要发生在服务器 / Next.js rendering/data layer:
Browser
↓
Next.js Server
↓
[Next Cache]
↓
DB / API / Owning Service
它主要优化:
- 重复服务器读取
- RSC / Page / Component 计算
- 服务端渲染结果复用
- 多个请求共享的稳定数据读取
TanStack Query
主要发生在浏览器 QueryClient:
Browser
├── UI
└── [TanStack Query Cache]
↓
API
↓
Server
它主要解决:
- 浏览器已经打开页面后的 server-state 生命周期
- background refetch
- window focus / reconnect 同步
- mutation lifecycle
- optimistic update
- pagination / infinite query
- query observer / loading / fetching 状态
所以:
Next Cache
= 服务端读取/渲染优化
TanStack Query
= 客户端远程状态同步
最大区别二:共享范围不同
TanStack Query 的 Query Cache 通常属于一个 QueryClient / 浏览器应用实例。
User A Browser
→ Query Cache A
User B Browser
→ Query Cache B
Next.js Server Cache 则运行在服务端,并可能跨请求复用缓存结果,因此跨用户隔离与 cache identity 更敏感。
这意味着:
私人用户数据
+
错误 server cache key
=
可能造成严重的数据隔离问题
所以 Next.js server cache 不能简单照搬“浏览器里 queryKey 写得差一点最多显示错状态”的风险模型。
queryKey 与 cacheTag 不是同一个东西
一个常见错误类比:
queryKey === cacheTag
不准确。
TanStack Query:
queryKey
→ Query Cache entry 的核心 identity
Next.js:
cached function + inputs
→ cache identity 的一部分
cacheTag
→ 给缓存增加可用于 invalidation 的业务标签
因此 cacheTag("products") 更接近:
“以后哪些缓存应该一起失效?”
而不是单纯等价于 Query Cache 地址。
staleTime 与 cacheLife 也不是一一对应
TanStack Query:
staleTime
→ 当前 Query projection 多久被视为 fresh
gcTime
→ inactive cache entry 多久后可被回收
Next.js Cache Components:
cacheLife.stale
→ Client Router 直接复用窗口
cacheLife.revalidate
→ Server Cache 后台刷新节奏
cacheLife.expire
→ Server Cache 必须重新生成的最大边界
所以:
cacheLife.stale ≠ staleTime 的同一层语义
cacheLife.expire ≠ gcTime
只能类比“都有 freshness/lifetime policy”,不能逐字段翻译。
revalidateTag 与 invalidateQueries
这两个是最接近的概念类比之一。
TanStack Query:
queryClient.invalidateQueries({
queryKey: ["products"],
})
表达:
我不再相信客户端 projection 足够新,相关 Query 应重新同步。
Next.js:
revalidateTag("products", "max")
表达:
相关服务器缓存不应继续长期被视为 fresh,可以以 stale-while-revalidate 方式更新。
共同思想:
known-stale projection
→ revoke freshness trust
→ re-sync from authority
但它们操作的是两个不同缓存层。
updateTag 不是 TanStack Query 乐观更新
这点必须明确。
Next.js updateTag
Server mutation succeeds
↓
truth 已经改变
↓
updateTag()
↓
server cache 立即 expire
↓
下一次 read 获取 fresh result
它是:
confirmed write 后的 immediate server-cache invalidation。
TanStack Query Optimistic Update
Mutation 尚未确认
↓
客户端先预测成功
↓
UI / Query Cache 先改
↓
Server result
├─ success → reconcile
└─ failure → rollback
它是:
在 authority 确认之前做预测性 UI 更新。
所以:
updateTag
≠ optimistic update
如果 Next.js 应用需要乐观 UI,仍然可以使用 React useOptimistic、TanStack Query optimistic mutation 等客户端机制;updateTag 解决的是另一层问题。
updateTag 与 setQueryData 也不等价
TanStack Query:
queryClient.setQueryData(key, authoritativeResult)
可以直接把已知的新 projection 写进客户端 cache。
Next.js updateTag():
不直接告诉 cache 新值是什么
↓
只是让旧 server cache 立即失效
↓
下一次 read 再向 source 获取 fresh result
因此:
setQueryData
= client knows new projection
updateTag
= server cache old projection must not be reused
两层缓存如何一起工作
一个完整系统可能是:
PostgreSQL / Go Service
↓ truth
Next.js Server Cache
↓ server projection
RSC / HTML / API
↓
TanStack Query Cache
↓ client projection
Interactive UI
Mutation 后可能需要分别处理:
1. owning service commit
2. Next server cache invalidation
3. client Query cache reconciliation / invalidation
是否三步都需要,取决于实际数据路径。
一个 MemoFlow 示例
初次加载 /goals
Next Server
→ cached Goal list
→ RSC
→ Browser
浏览器进入复杂交互模式
TanStack Query
→ 管理 filter / refetch / optimistic mutation / background sync
用户创建 Goal
真正乐观方案可能是:
Client TanStack Query
→ 先显示 optimistic Goal
Server Action / API
→ Owning Service commit
Server
→ updateTag("goals")
Client
→ reconcile / invalidate Query
注意其中:
optimistic UI
和
server-cache invalidation
是两个独立动作。
多层缓存最大的风险
Go cache 5 min
Next cache 10 min
Query cache 3 min
CDN cache 5 min
如果没有清晰策略,会出现:
数据到底在哪一层旧了?谁负责 invalidation?
所以每一层至少要能回答:
Who owns truth?
What is cached?
What is the identity?
How stale may it be?
Who invalidates it?
How does it converge?
这也是 缓存策略与一致性 的通用原则。
选型总结
| 问题 | Next.js Cache | TanStack Query |
|---|---|---|
| 主要运行位置 | Server / RSC layer | Browser / QueryClient |
| 核心目标 | 复用服务器读取与渲染结果 | 管理客户端 server-state projection |
| 身份 | cached function/input + tag | queryKey |
| 时间策略 | cacheLife | staleTime / gcTime |
| 主动失效 | revalidateTag / updateTag / path | invalidateQueries / setQueryData / removeQueries |
| 乐观更新 | 不是 updateTag 的职责 | 一等使用场景 |
| 实时交互 | 通常需配合 runtime streaming | 可和 SSE / WebSocket 协作 |
最短记忆
思想相似:
identity + freshness + invalidation + reconciliation
但不是同一层:
Next.js Cache = server projection
TanStack Query = client projection
最容易记错:
updateTag ≠ optimistic update