Skip to content

MUI v7 vs Radix + Tailwind: A Complete Performance and DX Experiment Design

MUI v7 vs Radix + Tailwind:效能與 DX 實驗設計全紀錄

"MUI is slow because of Emotion." You've heard this. Maybe you've even said it. But how slow, exactly? Is it 5ms or 500ms? Does it matter for your specific use case? And is the alternative — Radix + Tailwind — actually faster, or just perceived as faster because of community narrative?

Opinions are cheap. Controlled experiments are not.

This article documents the complete design of a controlled experiment comparing MUI v7 + Emotion against Radix UI + Tailwind CSS — covering performance and developer experience (DX) — built on a real KPI Dashboard scenario. Every architecture decision, every measurement choice, and every statistical method was debated by a panel of specialists and resolved by vote.

This is not the experiment results. This is the experiment design — the rigorous foundation that makes the results trustworthy.

「MUI 因為 Emotion 所以很慢。」你聽過這句話。也許你自己也說過。但到底慢多少?是 5ms 還是 500ms?對你的具體場景重要嗎?而替代方案——Radix + Tailwind——真的比較快,還是只是因為社群敘事而感覺比較快?

觀點很廉價,控制實驗不是。

本文記錄了一個對照實驗的完整設計:比較 MUI v7 + EmotionRadix UI + Tailwind CSS 的效能和開發者體驗(DX),建立在一個真實的 KPI Dashboard 場景上。每一個架構決策、每一個測量選擇、每一個統計方法都經過專家圓桌辯論並以投票決議。

這不是實驗結果。這是實驗設計——讓結果值得信賴的嚴謹基礎。


1. The Core Hypothesis

1. 核心命題

Hypothesis: MUI components carry runtime logic costs (Emotion serialization, theme lookup, CSS injection) that Radix + Tailwind components don't (static classNames).

Purpose: Measure exactly how large this runtime cost is, in which scenarios it manifests, and whether it's large enough to influence library selection.

Every architecture debate, Suspense boundary decision, and state management choice in this document serves one goal: control other variables so that this core hypothesis can be cleanly measured.

假設:MUI 的元件有 runtime 邏輯成本(Emotion 序列化、theme lookup、CSS injection),而 Radix + Tailwind 的元件沒有這些成本(靜態 className)。

目的:量測這個 runtime 邏輯成本到底是多少、在哪些場景下顯現、是否大到值得影響 library 選擇。

本文中所有的架構爭議、Suspense boundary 設計、state 管理方案選擇,都服務於一個目標:控制其他變因,讓這個核心命題能被乾淨地量測


2. Technology Stack

2. 技術棧確認

ItemSelectionRationale
MUI Versionv7 (default Emotion)Pigment CSS still in alpha, not practical
Radix VersionRadix Primitives + Tailwind CSSThe representative headless + utility-first combo
Groups2MUI v7 + Emotion vs Radix + Tailwind
FrameworkNext.js 15 (App Router)Modern React SSR standard
Data FetchingTanStack Query v5Shared between both groups, eliminates data layer variance
Mock APIMSW + 50ms fixed delayFixed latency, eliminates API variance
項目選定方案理由
MUI 版本v7(預設 Emotion)Pigment CSS 仍為 alpha,不實用
Radix 版本Radix Primitives + Tailwind CSSHeadless + utility-first 的代表組合
實驗組數2 組MUI v7 + Emotion vs Radix + Tailwind
框架Next.js 15 (App Router)現代 React SSR 標準
Data FetchingTanStack Query v5兩邊共用,消除 data layer 差異
Mock APIMSW + 50ms fixed delay固定延遲,消除 API 變異

3. The Benchmark: KPI Dashboard Specification

3. 基準測試對象:KPI Dashboard 規格

3.1 UI Layout

┌─────────────────────────────────────────────────┐
│  Sidebar (static nav, excluded from perf test)   │
│  ┌───────────────────────────────────────────┐   │
│  │  [Account A ▾]          ◀  Jan 2026  ▶   │   │
│  ├───────────────────────────────────────────┤   │
│  │  ┌─────┐ ┌─────┐ ┌─────┐                 │   │
│  │  │KPI 1│ │KPI 2│ │KPI 3│                 │   │
│  │  └─────┘ └─────┘ └─────┘                 │   │
│  │  ┌─────┐ ┌─────┐                         │   │
│  │  │KPI 4│ │KPI 5│                         │   │
│  │  └─────┘ └─────┘                         │   │
│  └───────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘

3.2 KPI Card Specification

Each KPI Card contains:

  • Title (fixed text)
  • Value (from API)
  • Trend arrow icon (fixed representative color, not data-driven)
  • Hover effect: subtle shadow elevation
  • No ripple, no animation, no sparkline

3.3 Filter Controls

  • AccountDropdown (select): Choose account (individual / all)
  • MonthNavigator (◀ ▶ buttons): Navigate year/month

3.4 API Design

APIEndpointDescription
Account listGET /api/accountsFor dropdown, fetched once then cached (staleTime: Infinity)
KPI dataGET /api/kpi?year=2026&month=1&account=allReturns 5 KPI values, varies by year/month/account

3.5 Critical Style Decision: All Fixed Styles

All KPI Card styles are fixed (pure representative colors). No CSS changes dynamically based on data. This means:

  • Emotion cache hits 100% after first render
  • Trend arrow colors are fixed representative colors (not data-driven)
  • No continuous or arbitrary dynamic styles

This is a deliberate design choice that gives MUI its best-case scenario for Emotion caching. If MUI still shows measurable overhead here, the gap would only widen in more dynamic scenarios.

3.1 UI 規格

┌─────────────────────────────────────────────────┐
│  Sidebar(靜態導航列,不參與效能測試)             │
│  ┌───────────────────────────────────────────┐   │
│  │  [帳號A ▾]              ◀  2026年1月  ▶   │   │
│  ├───────────────────────────────────────────┤   │
│  │  ┌─────┐ ┌─────┐ ┌─────┐                 │   │
│  │  │KPI 1│ │KPI 2│ │KPI 3│                 │   │
│  │  └─────┘ └─────┘ └─────┘                 │   │
│  │  ┌─────┐ ┌─────┐                         │   │
│  │  │KPI 4│ │KPI 5│                         │   │
│  │  └─────┘ └─────┘                         │   │
│  └───────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘

3.2 KPI Card 規格

每張 KPI Card 包含:

  • 標題(固定文字)
  • 數值(來自 API)
  • 趨勢箭頭 icon(固定代表色,非 data-driven)
  • Hover 效果:subtle shadow elevation
  • ripple、 animation、 sparkline

3.3 篩選控制項

  • AccountDropdown(下拉選單):選帳號(個別帳號 / 全部帳號)
  • MonthNavigator(◀ ▶ 按鈕):切換年月

3.4 API 設計

API端點說明
帳號列表GET /api/accounts供下拉選單使用,fetch 一次後 cache(staleTime: Infinity
KPI 資料GET /api/kpi?year=2026&month=1&account=all一次回傳 5 個 KPI 數據,依年/月/帳號變化

3.5 關鍵樣式決策:全固定樣式

所有 KPI Card 的樣式都是固定的(純代表色),沒有任何根據資料動態改變的 CSS。這意味著:

  • Emotion cache 在首次 render 後 100% 命中
  • 趨勢箭頭顏色是固定代表色(非 data-driven)
  • 無連續值或任意值的動態樣式

這是刻意的設計選擇,給了 MUI 在 Emotion 快取上的最佳情境。如果 MUI 在這裡仍然顯示可量測的額外成本,差距在更動態的場景下只會更大。


4. Next.js Rendering Model: The Context That Shapes Architecture

4. Next.js 渲染模型:塑造架構的背景知識

The architecture decisions in Section 5 cannot be understood without first clarifying how Next.js rendering actually works. Several common misconceptions directly influenced the experiment's design evolution.

4.1 PPR Status in Next.js 15 / 16

PPR (Partial Prerendering) is not the default in either version:

AspectNext.js 15Next.js 16
PPR StatusExperimentalStabilized via Cache Components
How to Enableexperimental: { ppr: 'incremental' }cacheComponents: true
Default?NoNo
Default RenderingStatic / Dynamic (auto-switches based on Dynamic API usage)Same (when cacheComponents not enabled)

Without PPR enabled, the default logic is: no Dynamic API → Static Rendering (build time); uses Dynamic API → the entire route switches to Dynamic Rendering (request time). PPR solves exactly this "all-static or all-dynamic per route" limitation.

4.2 Component Types and Rendering Timing

The key factor determining when a component renders is not sync/async — it's whether the component can complete its work during the prerendering phase.

At build time, Next.js renders your route's component tree. As long as components don't access network resources, certain system APIs, or require an incoming request to render, their output is automatically added to the static shell. — Next.js Documentation

Component TypeInitial HTML TimingNotes
Sync Server Component (no dynamic API)Build-timeNo async, no dynamic API → automatically in static shell
Async Server Component + use cacheBuild-time (cached)Result cached and included in static shell, updatable via revalidation
Async Server Component + <Suspense> (no cache)Request-time (streaming)This is the true "dynamic hole" in PPR
Client Component ("use client")Follows route strategy (can be build-time)"use client" only determines client hydration, does not affect when HTML is generated

Common misconceptions debunked:

  • "use client" does NOT mean client-side rendering — in Next.js, Client Components still have their initial HTML generated by the server. "use client" only marks "this component needs client-side hydration"
  • use cache async components are NOT request-time rendered — the opposite: use cache exists precisely to cache async results into the static shell
  • Sync does NOT guarantee build-time — the key is whether request-specific data (like cookies(), headers()) is accessed

4.3 The Rendering Spectrum

Next.js decomposes the traditional "CSR vs SSR" binary into a finer-grained spectrum:

Fully Static (Build-time)        Fully Dynamic (Request-time)           Pure CSR
◄────────────────────────────────────────────────────────────────────────────►
  Sync SC          use cache         Suspense-wrapped               Traditional CRA
  (static shell)   (cached shell)    Async SC (streaming)           (blank HTML)

  ← Next.js scope: server generates HTML in ALL cases →            ← Next.js
                                                                     doesn't do this

Three time points exist in Next.js, not just two:

Time PointWhoWhat HappensLegacy Equivalent
Build-timeBuild ServerPre-render static HTML, served directly from CDNLike SSG
Request-time (Server)Application ServerServer renders HTML per requestLike traditional SSR
Hydration-time (Client)BrowserJavaScript activates, attaches event handlers, enables stateThis is the interactive CSR layer

4.4 What Does use cache Actually Cache?

use cache caches neither "raw data" nor "complete static HTML files" — it caches the React Server Component rendering result (RSC Payload):

Data Layer                Component Render Layer              Full Page Layer
(raw JSON)               (RSC Payload)                      (Full HTML)


                         What use cache caches

The granularity is up to you:

Where use cache is placedWhat's cached
Function level async function getData()Function return value (close to caching data)
Component level async function MyComponent()Component's RSC Payload (rendered component tree)
File level (top of page)Entire page's RSC Payload (close to caching the full page)

第 5 節的架構決策如果沒有先釐清 Next.js 渲染的實際運作方式,就無法被理解。幾個常見誤解直接影響了實驗的設計演進。

4.1 PPR 在 Next.js 15 / 16 的狀態

PPR(Partial Prerendering)在兩個版本中都不是預設

面向Next.js 15Next.js 16
PPR 狀態Experimental(實驗性)透過 Cache Components 穩定化
啟用方式experimental: { ppr: 'incremental' }cacheComponents: true
是否為預設
預設渲染模式Static / Dynamic(依據是否使用 Dynamic API 自動切換)同左(未開啟 cacheComponents 時)

未啟用時,Next.js App Router 的預設邏輯是:沒有使用 Dynamic API → Static Rendering(build time);使用了 Dynamic API → 整個 route 切換為 Dynamic Rendering(request time)。PPR 要解決的正是這個「一個 route 只能全靜態或全動態」的痛點。

4.2 元件類型與渲染時機

決定元件何時渲染的關鍵不是 sync/async,而是元件在 prerendering 階段能不能完成它的工作

At build time, Next.js renders your route's component tree. As long as components don't access network resources, certain system APIs, or require an incoming request to render, their output is automatically added to the static shell. — Next.js 官方文件

元件類型初始 HTML 產生時機備註
Sync Server Component(無 dynamic API)Build-time無 async、無 dynamic API,自動納入 static shell
Async Server Component + use cacheBuild-time(快取結果)結果被快取並納入 static shell,可透過 revalidation 更新
Async Server Component + <Suspense>(無 cache)Request-time(streaming)這才是真正的 dynamic,PPR 的「洞」(dynamic hole)
Client Component("use client"跟隨所在 route 的策略(可以是 build-time)"use client" 只決定 hydration 在 client,不影響 HTML 何時產生

常見誤解澄清:

  • "use client" 不等於 client-side rendering——在 Next.js 中,Client Component 的初始 HTML 仍由 server 產生,"use client" 只是標記「這個元件需要 client-side hydration」
  • use cache 的 async component 不是 request-time 渲染——恰好相反,use cache 的目的正是讓 async 元件的結果被快取並納入 static shell
  • Sync 不保證 build-time——關鍵不是 sync/async,而是是否存取了 request-specific 的資料(如 cookies()headers()

4.3 Next.js 渲染光譜

Next.js 把傳統「CSR vs SSR」的二元對立,拆解成了更細粒度的光譜:

完全 Static (Build-time)          完全 Dynamic (Request-time)          純 CSR
◄──────────────────────────────────────────────────────────────────────────►
  Sync SC          use cache           Suspense 包的              傳統 CRA
  (static shell)   (cached shell)      Async SC (streaming)       (空白HTML)

  ← Next.js 的範圍,所有情境 server 都會產生 HTML →               ← Next.js
                                                                     不做這個

Next.js 裡有三個時間點,而不是只有 CSR 和 SSR 兩個:

時間點誰在做發生什麼對應舊觀念
Build-timeBuild Server預渲染靜態 HTML,部署後直接從 CDN 送出類似 SSG
Request-time (Server)Application Server每次 request 到來時,server 才渲染 HTML類似傳統 SSR
Hydration-time (Client)BrowserJavaScript 啟動,掛載 event handlers、啟用 state這才是 CSR 的互動層

4.4 use cache 快取的是什麼?

use cache 快取的不是「原始資料」也不是「完整 HTML 靜態檔」,而是 React Server Component 的渲染結果(RSC Payload)

資料層                    元件渲染結果層                   完整頁面層
(raw JSON)               (RSC Payload)                  (Full HTML)


                         use cache 快取的東西

粒度是你決定的:

use cache 放的位置快取的內容
函式層級 async function getData()該函式的回傳值(接近快取資料)
元件層級 async function MyComponent()該元件的 RSC Payload(渲染好的元件樹)
檔案層級(頁面最上方)整個頁面的 RSC Payload(接近快取整頁)

5. Architecture Design: From Ideal to Pragmatic

5. 架構設計:從理想到務實

5.1 The Evolution

The design went through multiple rounds, from an extreme leaf-node architecture to a pragmatic single client boundary.

Initial Proposal: Extreme Leaf Node Architecture

DashboardPage (SC)
├── Sidebar (SC)
└── KPISection (SC)
    ├── <Suspense>
    │   └── AccountDropdown (CC)
    └── KPICard × 5 (SC)
        ├── CardTitle (SC)
        ├── <Suspense>
        │   └── KPINumber (CC)
        └── CardDescription (SC)

Problem: How do multiple disconnected Client Components share filter state (year, month, accountId)?

State-sharing approaches considered:

ApproachDescriptionProblem
URL Search Paramsrouter.push('?account=newId') triggers server navigationAccount switch adds server round-trip, slower than MUI
Shared Client ContextAdd Provider at top levelClient boundary gets pushed up, degrades to non-leaf
nuqs (URL state library)URL as shared stateMay still trigger server re-render
ZustandModule-level storeViable, but introduces additional dependency
TanStack Query CachesetQueryData + invalidateQueriesUsing server state manager for client state, design intent mismatch

5.2 The Key Insight: Why Leaf Node Yields Near-Zero Benefit

Server Components save "component JavaScript runtime logic." But Tailwind KPI Cards have no JS runtime logic to save:

tsx
// Tailwind KPICard — just a div + className
function KPICard({ title, description, children }) {
  return (
    <div className="rounded-lg border p-4 hover:shadow-md transition-shadow">
      <span className="text-sm text-gray-500">{title}</span>
      {children}
      <span className="text-xs text-gray-400">{description}</span>
    </div>
  )
}
// As SC: server executes → sends HTML → saves "calling this function on client"
// As CC: client calls this function → produces identical HTML
// Difference ≈ 0.01ms (calling a function that just returns JSX)

Compare with MUI's Card, which has real runtime logic (read theme context → Emotion serialization → CSS hash → DOM injection), but MUI components cannot be Server Components due to Emotion's browser Context dependency.

JS runtime cost saved as SCCan it be SC?
MUI <Card>High (Emotion serialization + theme lookup + DOM injection)No (Emotion constraint)
Tailwind <div className>≈ 0 (just a function returning JSX)Yes, but nothing to save

Meanwhile, the leaf-node costs are real:

  • Each leaf needs its own <Suspense> boundary → 7 loading skeletons, inconsistent UX
  • Using Query Cache for client state → design intent mismatch, code duplication
  • Each leaf repeats 3 useQuery(['selectedXxx']) calls → maintenance burden

5.3 Final Architecture

MUI v7:
─────────────
DashboardPage ("use client")        ← Emotion forces full-page client
├── Sidebar                         ← Pulled into client bundle (~10-15 KB)
└── KPIDashboard
    ├── FilterBar
    │   ├── AccountDropdown
    │   └── MonthNavigator (◀ ▶)
    └── KPIGrid
        └── KPICard × 5

Radix + Tailwind:
─────────────
DashboardPage (Server Component)
├── Sidebar (Server Component)      ← Not in client bundle ✅
└── <Suspense>
    └── KPIDashboard ("use client") ← Client boundary here
        ├── FilterBar
        │   ├── AccountDropdown
        │   └── MonthNavigator (◀ ▶)
        └── KPIGrid
            └── KPICard × 5

The internal logic of KPIDashboard is 100% identical on both sides (same useState, same useQuery, same prop drilling). The only differences:

  1. Whether Sidebar is in client bundle (MUI: yes / Radix: no)
  2. Styling engine (Emotion runtime vs Tailwind static class)
  3. Component JS (MUI Card/Select/Typography vs Radix Select + <div className>)

5.4 Why Sidebar Should Stay a Server Component

Sidebar is the only component worth making SC, because:

  • It doesn't share state with the KPI area → naturally independent
  • In MUI: contains ListItem, Icon, etc. → real JS to save
  • In Radix: contains nav link + icon → less JS but still non-zero
  • Zero cost (it doesn't interact with any CC)

5.5 JSX Comparison: The UI Layer Differences

AspectMUI v7Radix + Tailwind
Card<Card> + <CardContent> — two wrappers, built-in padding/border/elevation<div className="rounded-lg border p-4"> — one div + utility classes
Text<Typography variant="h4"> — theme token mapping<div className="text-3xl font-bold"> — direct utility class
Select<Select> + <MenuItem> — 2 imports, built-in portal/animation/ripple<Select.Root> + 6 sub-components — more JSX lines, each minimized
Button<IconButton> — built-in ripple + focus ring<button className="..."> — native button + utility class
Layout<Box sx={{ display: 'grid' }}> — runtime CSS generation<div className="grid grid-cols-3 gap-4"> — build-time static class
Hoversx={{ '&:hover': { boxShadow: 4 } }} — Emotion runtimeclassName="hover:shadow-md" — Tailwind static class

Logic layer (state, hooks, event handlers) = 100% identical. Differences are entirely in the UI rendering layer.

5.6 Shared Internal Logic (Both Sides Identical)

tsx
// State management
const [accountId, setAccountId] = useState('all')
const [year, setYear] = useState(2026)
const [month, setMonth] = useState(1)

// Data Fetching
const { data: accounts } = useQuery({
  queryKey: ['accounts'],
  queryFn: fetchAccounts,
  staleTime: Infinity,
})

const { data: kpiData } = useQuery({
  queryKey: ['kpi', year, month, accountId],
  queryFn: () => fetchKPIs(year, month, accountId),
})

// Month navigation
const goToPrevMonth = () => {
  if (month === 1) { setYear(y => y - 1); setMonth(12) }
  else { setMonth(m => m - 1) }
}
const goToNextMonth = () => {
  if (month === 12) { setYear(y => y + 1); setMonth(1) }
  else { setMonth(m => m + 1) }
}

5.1 架構演進

設計經歷了多輪討論,從極致 leaf node 架構逐步收斂到務實的單一 client boundary 架構。

最初提出:極致 Leaf Node 架構

DashboardPage (SC)
├── Sidebar (SC)
└── KPISection (SC)
    ├── <Suspense>
    │   └── AccountDropdown (CC)
    └── KPICard × 5 (SC)
        ├── CardTitle (SC)
        ├── <Suspense>
        │   └── KPINumber (CC)
        └── CardDescription (SC)

問題:多個不相連的 Client Component 之間如何共享篩選狀態(year, month, accountId)?

討論過的共享狀態方案:

方案說明問題
URL Search Paramsrouter.push('?account=newId') 觸發 server navigation帳號切換多了 server round-trip,比 MUI 慢
Shared Client Context在上層加 Providerclient boundary 被拉高,退化成非 leaf node
nuqs (URL state library)URL 是共享 state仍可能觸發 server re-render
ZustandModule-level store可行,但引入額外依賴
TanStack Query CachesetQueryData + invalidateQueries用 server state manager 存 client state,設計意圖不符

5.2 關鍵洞察:為什麼 Leaf Node 收益趨近於零

Server Component 省下的是「元件的 JavaScript runtime 邏輯」。但 Tailwind 版的 KPICard 本來就沒有 JS runtime 邏輯可省

tsx
// Tailwind 版 KPICard — 就是一個 div + className
function KPICard({ title, description, children }) {
  return (
    <div className="rounded-lg border p-4 hover:shadow-md transition-shadow">
      <span className="text-sm text-gray-500">{title}</span>
      {children}
      <span className="text-xs text-gray-400">{description}</span>
    </div>
  )
}
// 作為 SC:server 執行 → 送 HTML → 省下「在 client 呼叫此 function」
// 作為 CC:client 呼叫此 function → 產出同樣的 HTML
// 差距 ≈ 0.01ms(呼叫一個只 return JSX 的 function)

對比 MUI 的 Card,有真正的 runtime 邏輯(讀 theme context → Emotion 序列化 → CSS hash → DOM 注入),但 MUI 的元件因為依賴 Emotion 的 browser Context,做不到 Server Component。

作為 SC 能省下的 JS runtime cost能否做到 SC
MUI <Card>高(Emotion 序列化 + theme lookup + DOM 注入)不行(Emotion 限制)
Tailwind <div className>≈ 0(就是一個 function return JSX)可以,但沒什麼好省的

Leaf node 的成本卻是實實在在的:

  • 每個 leaf 需獨立的 <Suspense> boundary → 7 個 loading skeleton,UX 不一致
  • 用 Query Cache 存 client state → 非設計意圖,程式碼重複
  • 每個 leaf 都要重複讀 3 個 useQuery(['selectedXxx']) → 維護困難

5.3 最終架構

MUI v7 方案:
─────────────
DashboardPage ("use client")        ← Emotion 強制整頁 client
├── Sidebar                         ← 被拉進 client bundle(~10-15 KB)
└── KPIDashboard
    ├── FilterBar
    │   ├── AccountDropdown
    │   └── MonthNavigator (◀ ▶)
    └── KPIGrid
        └── KPICard × 5

Radix + Tailwind 方案:
─────────────
DashboardPage (Server Component)
├── Sidebar (Server Component)      ← 不在 client bundle ✅
└── <Suspense>
    └── KPIDashboard ("use client") ← client boundary 在這
        ├── FilterBar
        │   ├── AccountDropdown
        │   └── MonthNavigator (◀ ▶)
        └── KPIGrid
            └── KPICard × 5

兩邊的 KPIDashboard 內部邏輯完全一致(同樣的 useState、同樣的 useQuery、同樣的 prop drilling)。唯一差異:

  1. Sidebar 是否在 client bundle(MUI: 是 / Radix: 否)
  2. Styling engine(Emotion runtime vs Tailwind 靜態 class)
  3. Component JS(MUI Card/Select/Typography vs Radix Select + <div className>

5.4 Sidebar 為何應保持 Server Component

Sidebar 是唯一值得做 SC 的元件,因為:

  • 不需要跟 KPI 區域共享 state → 天然獨立
  • MUI 情況下:含 ListItem、Icon 等 → 有實質 JS 可省
  • Radix 情況下:含 nav link + icon → JS 較少但仍非零
  • 成本為零(它本來就不跟其他 CC 互動)

5.5 JSX 差異:UI 渲染層的不同

面向MUI v7Radix + Tailwind
Card<Card> + <CardContent> — 兩層包裝,自帶 padding/border/elevation<div className="rounded-lg border p-4"> — 一個 div + utility classes
文字<Typography variant="h4"> — theme token 映射<div className="text-3xl font-bold"> — 直接 utility class
Select<Select> + <MenuItem> — 兩個 import,內建 portal/animation/ripple<Select.Root> + 6 個子元件 — 更多 JSX 行數,各自最小化
按鈕<IconButton> — 自帶 ripple + focus ring<button className="..."> — 原生 button + utility class
Layout<Box sx={{ display: 'grid' }}> — runtime 生成 CSS<div className="grid grid-cols-3 gap-4"> — build-time 靜態 class
Hoversx={{ '&:hover': { boxShadow: 4 } }} — Emotion runtime 處理className="hover:shadow-md" — Tailwind 靜態 class

邏輯層(state、hooks、event handlers)= 100% 一致。差異完全在 UI 渲染層。

5.6 共用內部邏輯(兩邊完全一致)

tsx
// State 管理
const [accountId, setAccountId] = useState('all')
const [year, setYear] = useState(2026)
const [month, setMonth] = useState(1)

// Data Fetching
const { data: accounts } = useQuery({
  queryKey: ['accounts'],
  queryFn: fetchAccounts,
  staleTime: Infinity,
})

const { data: kpiData } = useQuery({
  queryKey: ['kpi', year, month, accountId],
  queryFn: () => fetchKPIs(year, month, accountId),
})

// 月份切換
const goToPrevMonth = () => {
  if (month === 1) { setYear(y => y - 1); setMonth(12) }
  else { setMonth(m => m - 1) }
}
const goToNextMonth = () => {
  if (month === 12) { setYear(y => y + 1); setMonth(1) }
  else { setMonth(m => m + 1) }
}

6. Emotion Cache Behavior Analysis (Under This Spec)

6. Emotion 快取行為分析(本 Spec 下)

6.1 First Render (S1)

Serialize all sx props → generate CSS → hash → inject <style> tag
Highest cost (serialization + DOM operations + Style Recalculation)

6.2 Account/Month Switch Re-render (S2)

Serialize sx prop (even if values are identical, due to referential inequality)
→ hash → cache lookup → HIT ✅
→ No new <style> tag injected
→ Cost: only serialization computation (~0.2ms per styled component)
→ 5 Cards × 4-5 styled elements ≈ ~4-5ms total

6.3 Comparison: Tailwind on Re-render

MUI re-render flow:
  Each <Box sx={...}> → Emotion serialize → hash → cache lookup → ~0.2ms
  ~20 styled components × ~0.2ms = ~4ms

Tailwind re-render flow:
  Each <div className="..."> → static string, direct assignment → ~0ms extra cost

Emotion's re-render cost is O(n) where n = number of styled components. Tailwind's is O(0). Under this spec the gap is small (~4ms), but it scales linearly as component count grows.

6.1 首次 Render(S1)

序列化所有 sx prop → 產生 CSS → hash → 注入 <style> tag
成本最高(序列化 + DOM 操作 + Style Recalculation)

6.2 帳號切換 / 月份切換 Re-render(S2)

序列化 sx prop(即使值相同,因 referential inequality)
→ hash → 查 cache → 命中 ✅
→ 不注入新 <style> tag
→ 成本:只有序列化計算(~0.2ms per styled component)
→ 5 張 Card × 4-5 個 styled elements ≈ ~4-5ms total

6.3 對比 Tailwind 在 Re-render 時

MUI re-render 流程:
  每個 <Box sx={...}> → Emotion 序列化 → hash → cache lookup → ~0.2ms
  ~20 個 styled component × ~0.2ms = ~4ms

Tailwind re-render 流程:
  每個 <div className="..."> → 靜態字串,直接賦值 → ~0ms 額外成本

Emotion 的 re-render 成本是 O(n),n 是 styled component 的數量。Tailwind 的是 O(0)。 目前 spec 下差異小(~4ms),但元件數量增加時差異線性增長。


7. Performance Experiment Design

7. 效能實驗設計

7.1 Test Scenarios

ScenarioOperationMeasurement Focus
S1: Cold StartClear browser profile → first loadJS bundle size, TBT, LCP, hydration scope
S2: Filter SwitchSwitch account / click ◀▶ to change month → refetchReact Profiler render duration, INP

Removed scenario: S3: Stress Re-render (update KPI every 500ms) — no continuous dynamic styles in this spec, unrealistic.

7.2 Measurement Metrics

PriorityMetricToolScenario
P0TBT (Total Blocking Time)Lighthouse CLIS1
P0Time to KPI (custom mark)performance.mark()S1, S2
P0JS bundle size (gzipped)@next/bundle-analyzerBuild
P0React Profiler render duration<Profiler onRender>S2
P1LCPLighthouse CLIS1
P1INPweb-vitals libraryS2
P1Style Recalculation count/timeChrome Performance tabS1
P1Memory usage (JS heap)performance.measureUserAgentSpecificMemory()Long-running

7.3 The sx Prop Decision

ApproachVoteRationale
Naive (no useMemo)3/5 — AdoptedReflects real-world typical usage
Optimized (useMemo every sx)1/5Library comparison should be at best state
Both1/5Run if time permits

In naive mode, Emotion re-serializes every sx prop on each render (even if values haven't changed), but doesn't inject new CSS when the cache hits.

7.4 Statistical Methodology

  • Minimum 25 runs per scenario
  • Report median + P95
  • Wilcoxon signed-rank test (non-parametric, suitable for right-skewed distributions)
  • Cliff's Delta for effect size
  • CV < 10% threshold to accept results
  • Browser profile cleared between every run

7.5 Environment Controls

  • MSW fixed 50ms delay (warm-up request to avoid Service Worker startup latency)
  • Production build (next buildnext start)
  • Deep imports (@mui/material/Card, not barrel imports)
  • Fixed viewport (1920×1080)
  • Two CPU throttling levels: no throttle + 4x CPU slowdown

7.6 Two-Round Experiment Design

Round 1 (Control)Round 2 (Real-World)
MUI v7 + Emotion"use client" at page level"use client" at page level (same as Round 1)
Radix + Tailwind"use client" at page level (intentionally downgraded)SC page + CC KPIDashboard (best practice)
What it measuresPure UI Library difference (styling engine + component JS)Full impact of library choice (including architecture differences)
Round 1 difference = Styling Engine + Component JS
Round 2 difference = Styling Engine + Component JS + Architecture (Sidebar SC vs CC)
Round 2 - Round 1 = Architecture contribution

Key insight: MUI's architecture is identical in Round 1 and Round 2 (locked into "use client" at page level by Emotion — no choice). Only one test run needed. The only additional work is Radix's Round 1 version (intentionally downgraded).

7.1 測試場景

場景操作測量重點
S1: Cold Start清空 browser profile → 首次載入JS bundle size、TBT、LCP、hydration 範圍
S2: 篩選切換切換帳號 / 點擊 ◀▶ 切換月份 → refetchReact Profiler render duration、INP

已移除的場景: S3: Stress Re-render(每 500ms 更新 KPI)——spec 無連續動態樣式,不切實際。

7.2 測量指標

優先級指標工具場景
P0TBT (Total Blocking Time)Lighthouse CLIS1
P0Time to KPI(custom mark)performance.mark()S1, S2
P0JS bundle size (gzipped)@next/bundle-analyzerBuild
P0React Profiler render duration<Profiler onRender>S2
P1LCPLighthouse CLIS1
P1INPweb-vitals libraryS2
P1Style Recalculation count/timeChrome Performance tabS1
P1Memory usage (JS heap)performance.measureUserAgentSpecificMemory()長時間

7.3 sx prop 寫法決策

方案投票理由
Naive(不加 useMemo)3/5 採用反映真實世界典型用法
Optimized(useMemo 每個 sx)1/5library 比較應在最佳狀態
兩組都跑1/5時間允許可做

Emotion 在 naive 寫法下,每次 render 都重新序列化 sx prop(即使值相同),但命中 cache 時不注入新 CSS。

7.4 統計方法

  • 每場景至少 25 次 run
  • median + P95
  • Wilcoxon signed-rank test(非參數檢驗,適合右偏分佈)
  • Cliff's Delta for effect size
  • CV < 10% 才接受結果
  • 每次 run 清除 browser profile

7.5 環境控制

  • MSW 固定 50ms delay(warm-up request 避免 Service Worker 啟動延遲)
  • Production build(next buildnext start
  • Deep import(@mui/material/Card,非 barrel import)
  • 固定 viewport(1920×1080)
  • 兩組 CPU throttling:no throttle + 4x CPU slowdown

7.6 兩輪實驗設計

Round 1(控制組)Round 2(實戰組)
MUI v7 + Emotion"use client" page 層級"use client" page 層級(同 Round 1)
Radix + Tailwind"use client" page 層級(刻意降級配合)SC page + CC KPIDashboard(最佳實踐)
測的是什麼純 UI Library 差異(styling engine + component JS)Library 選擇的全面影響(含架構差異)
Round 1 差異 = Styling Engine + Component JS
Round 2 差異 = Styling Engine + Component JS + 架構差異(Sidebar SC vs CC)
Round 2 - Round 1 = 架構差異的貢獻量

關鍵洞察:MUI 在 Round 1 和 Round 2 架構相同(被 Emotion 鎖在 "use client" page 層級,無選擇權),只需跑一次測試。真正多出的工作量只有 Radix 的 Round 1 版本(刻意降級版)。


8. DX Experiment Design

8. DX 實驗設計

8.1 Experiment Structure

Shared Preparation (not counted):
├── Test framework (Vitest + RTL)
├── MSW handlers
├── useQuery hooks + utils
└── Shared type definitions

Phase 1: UI Design (Human)
├── MUI version: design 5 KPI Card layout, colors, spacing
└── Radix version: same

Phase 2: Implementation (Cursor AI Agent)
├── MUI version: Cursor generates code from Phase 1 design
└── Radix version: same

8.2 DX Metrics

MetricPriorityDefinition
Iteration CountP0Rounds of conversation from first prompt to passing tests
Human Intervention CountP0Manual fixes to AI output
Test Pass RateP0AI's first-attempt test pass rate
Final Lines of CodeP1Final usable code line count
Total TokenP1Total tokens consumed
Wall Clock TimeP1Total elapsed time (excluding API wait)

If you could pick only one metric: Iteration Count. It's the most intuitive, hardest to game, and best reflects real AI-assisted development DX.

8.3 Prompt Fairness

Using fixed structure + library-specific details:

Role: Senior React developer
Stack: Next.js 15 + [MUI v7 / Radix + Tailwind] + TanStack Query v5
Task: Implement Dashboard per specification

Design spec:
- 5 KPI Cards (title, value, trend arrow)
- Hover shadow elevation
- Account dropdown + month nav buttons → refetch
- MSW mock API (configured)
- useQuery hook (wrapped)

Requirements:
- Use [library] best practices
- Only implement UI components (API/data layer done)
- Must pass pre-written tests

8.4 Human Intervention Counting Rules

Intervention TypeCounted?Rationale
Fix import pathYesAI not knowing optimal import = part of library DX
Fix TypeScript type errorYesLibrary type definition usability
Fix logic error (useQuery usage)NoUnrelated to UI library (data layer is extracted)
Adjust styling (color, spacing)DependsAI not knowing spacing scale → count; design preference → don't count
Additional prompt requesting changesYesThis IS a new iteration

8.1 實驗架構

前置準備(兩邊共用,不計入):
├── 測試框架(Vitest + RTL)
├── MSW handlers
├── useQuery hooks + utils
└── 共用型別定義

Phase 1: UI 程式設計(人工)
├── MUI 版:設計 5 張 KPI Card 佈局、配色、間距
└── Radix 版:同上

Phase 2: 程式實作(Cursor AI Agent)
├── MUI 版:用 Cursor 根據 Phase 1 設計產出程式碼
└── Radix 版:同上

8.2 DX 量測指標

指標優先級定義
Iteration CountP0從第一個 prompt 到通過測試需幾輪對話
Human Intervention CountP0手動修改 AI 輸出多少次
Test Pass RateP0AI 首次產出跑測試的通過率
Final Lines of CodeP1最終可用程式碼行數
Total TokenP1整個流程消耗的總 token
Wall Clock TimeP1全程耗時(排除 API 等待)

如果只選一個指標,選 Iteration Count。它最直觀、最難作弊、最能反映 AI-assisted 開發的真實 DX。

8.3 Prompt 公平性

採用固定結構 + library-specific 細節

角色:資深 React 開發者
技術棧:Next.js 15 + [MUI v7 / Radix + Tailwind] + TanStack Query v5
任務:根據規格實作 Dashboard

設計規格:
- 5 張 KPI Card(標題、數值、趨勢箭頭)
- Hover shadow elevation
- 帳號下拉選單 + 月份切換按鈕 → refetch
- MSW mock API(已設定)
- useQuery hook(已封裝)

要求:
- 使用 [library] 的最佳實踐
- 只實作 UI 元件(API/data layer 已完成)
- 需通過已寫好的測試

8.4 Human Intervention 計算標準

介入類型是否計入理由
修正 import pathAI 不知道最佳 import = library DX 的一部分
修正 TypeScript 型別錯誤library 型別定義的易用性
修正邏輯錯誤(useQuery 用法)跟 UI library 無關(data layer 已抽出)
調整樣式(顏色、間距)視情況AI 不知道 spacing scale → 計入;設計偏好 → 不計入
追加 prompt 要求修改這就是新的 iteration

9. Performance Predictions

9. 效能預測

9.1 Client Bundle Size Difference

MUI — client bundle contains:
├── React runtime                        ~40 KB
├── Emotion runtime                      ~10-15 KB
├── MUI ThemeProvider + Context           ~5 KB
├── MUI Card × 5 (with sub-component logic) ~20-30 KB
├── MUI Sidebar components (ListItem etc.) ~10-15 KB
├── AccountDropdown (MUI Select)          ~15-20 KB
├── TanStack Query                        ~12 KB
└── Business logic                        ~5 KB
    Total: ~120-145 KB gzipped

Radix — client bundle contains:
├── React runtime                        ~40 KB
├── Radix Select (for AccountDropdown)    ~5-8 KB
├── TanStack Query                        ~12 KB
├── KPIDashboard business logic           ~3-5 KB
└── lucide-react icons                    ~2-3 KB
    Total: ~62-68 KB gzipped

Gap: ~55-80 KB gzipped

9.2 Per-Scenario Predicted Differences

ScenarioPredicted Gap (no throttle)Predicted Gap (4x CPU)User Perception
S1: First Load100-200ms400-800msPerceivable (especially for infrequent users)
S2: Account/Month Switch~4ms~16msNearly imperceptible

9.3 Impact of Usage Frequency

This Dashboard is used fewer than 10 times per month. Users almost always arrive with a cold start (browser cache may be cleared).

Usage PatternS1 Gap PerceptionS2 Gap Accumulation
Low frequency (< 10/month)Felt every timeLow (few S2 interactions)
High frequency (daily)Only felt on first visitHigh (many S2 interactions)

Note: The 55-80 KB bundle difference is a fixed cost. Regardless of usage frequency, the absolute difference on each cold start is identical. Different frequencies change which scenario's gap is felt, not the gap itself.

9.1 Client Bundle Size 差異

MUI 方案 — client bundle 包含:
├── React runtime                        ~40 KB
├── Emotion runtime                      ~10-15 KB
├── MUI ThemeProvider + Context           ~5 KB
├── MUI Card × 5(含所有子元件邏輯)       ~20-30 KB
├── MUI Sidebar 元件(ListItem 等)        ~10-15 KB
├── AccountDropdown(MUI Select)          ~15-20 KB
├── TanStack Query                        ~12 KB
└── 業務邏輯                               ~5 KB
    總計約 120-145 KB gzipped

Radix 方案 — client bundle 包含:
├── React runtime                        ~40 KB
├── Radix Select(AccountDropdown 用)     ~5-8 KB
├── TanStack Query                        ~12 KB
├── KPIDashboard 業務邏輯                  ~3-5 KB
└── lucide-react icons                    ~2-3 KB
    總計約 62-68 KB gzipped

差距:~55-80 KB gzipped

9.2 各場景預估差異

場景預估差異(no throttle)預估差異(4x CPU)使用者感知
S1 首次載入100-200ms400-800ms可感知(尤其低頻使用者)
S2 帳號/月份切換~4ms~16ms幾乎無感

9.3 使用頻率的影響

此 Dashboard 每月使用不到 10 次,使用者每次來幾乎都是 cold start(browser cache 可能已清除)。

使用頻率S1 差異的感受S2 差異的累積
低頻(每月 <10 次)每次都感受到累積少(S2 次數少)
高頻(每天用)只有第一次感受到累積多(S2 次數多)

注意:55-80 KB 的 bundle 差異 = 固定成本。不管使用頻率高低,每次 cold start 的絕對差異相同。不同頻率下感受到差異的場景不同,而非差異本身被放大或縮小。


10. The Full Decision Framework: Beyond Performance

10. 完整決策框架:超越效能

Performance experiment results should not be the sole selection criteria. The complete decision framework:

Design Principle (Highest Priority)

  • MUI: Material Design as established language. The closer your product is to Material Design, the less you override
  • Radix: Headless, no default appearance. Full customization without "grow Material Design first, then override"

DX (Developer Experience)

  • MUI: Complete component set, thorough documentation, one import and it works
  • Radix: Requires assembling your own styling, building design tokens, higher initial investment

Maintainability

  • MUI: Upgrades may introduce breaking changes (v5→v6 Emotion→Pigment), but large community
  • Radix: Independent component versioning, lower upgrade risk, but Tailwind classes in complex components are hard to maintain

Performance (What This Experiment Measures)

Under this spec, the difference exists but is not large. Performance should not be the primary decision factor.

效能實驗的結論不應是唯一的選擇依據。完整的決策框架:

Design Principle(最優先)

  • MUI:Material Design 既定語言。產品越接近 Material Design,改越少
  • Radix:Headless,無預設外觀。完全客製化時不用「先長 MD 再覆蓋」

DX(開發者體驗)

  • MUI:元件齊全、文件完整、一個 import 就能用
  • Radix:需自己組裝 styling、建 design token、初始投入高

維護性

  • MUI:升級可能 breaking change(v5→v6 Emotion→Pigment),但社群大
  • Radix:元件獨立版本、升級風險低,但 Tailwind class 複雜元件難維護

效能(本實驗量測)

在此 spec 下差異存在但不大。效能不應是主要決策因素。


11. Conclusion and Validity Period

11. 結論與有效期限

Expected Conclusion

MUI v7 + Emotion carries a measurable extra cost on first load (S1) — estimated 100-200ms without throttling, 400-800ms with 4x CPU — primarily from Emotion runtime + MUI component JS bundle size. In daily interactions (S2 account/month switching), the difference is negligible (~4ms without throttling).

Radix + Tailwind has zero styling engine runtime cost on every re-render (Tailwind's className is a static string), while MUI's Emotion serialization cost is O(n), scaling linearly with styled component count.

Performance should not be the primary decision factor. Selection should prioritize the product's Design Principle (degree of customization needed), followed by team DX preference and long-term maintainability.

Validity Period

This conclusion is based on MUI v7 + Emotion. When MUI migrates to Pigment CSS (compile-time CSS-in-JS) as the default, the SSR architecture constraint disappears (components can become Server Components), runtime CSS cost is eliminated, and this conclusion may fundamentally change.

預期結論

MUI v7 + Emotion 在首次載入(S1)有可量測的額外成本(預估 100-200ms no throttle,400-800ms 4x CPU),主要來自 Emotion runtime + MUI 元件的 JS bundle 大小。在日常互動(S2 帳號/月份切換)中差異 negligible(預估 ~4ms no throttle)。

Radix + Tailwind 在每次 re-render 時沒有 styling engine 的 runtime 成本(Tailwind 的 className 是靜態字串),而 MUI 的 Emotion 序列化成本是 O(n),隨 styled component 數量線性增長。

效能不應是主要決策因素。選擇應優先根據產品的 Design Principle(客製化需求程度),其次是團隊 DX 偏好與長期維護性。

有效期限

此結論基於 MUI v7 + Emotion。未來 MUI 遷移至 Pigment CSS(compile-time CSS-in-JS)成為預設後,SSR 架構約束消失(元件可為 Server Component),runtime CSS 成本消除,結論可能根本性改變。


Appendix A: Dynamic Rendering Scenarios Reference

附錄 A:Dynamic 渲染情境參考

cookies() / headers()

Scenarios requiring per-user, per-request information to render:

ScenarioWhy NeededExample
Login stateDetermine display based on session cookie"Welcome back, Crystal" vs "Please log in"
Locale preferenceRead Accept-Language header or locale cookieSame /products page, TW users see Chinese, JP users see Japanese
A/B TestingExperiment group stored in cookieSame page shows new UI for group A, old UI for group B
Permission controlDetermine visible content based on auth tokenAdmin sees "Delete" button, regular users don't
Dark/light mode (server-side)Read theme preference cookie to avoid flickerCookie instead of localStorage to prevent FOUC

searchParams

URL query strings are potentially different on every request, impossible to pre-render at build time:

ScenarioURL ExampleWhy Needed
Search results/search?q=iphone&sort=priceInfinite combinations, can't pre-render all
Filter/facets/products?category=shoes&color=redSame
Pagination/blog?page=3Dynamic page numbers
UTM-driven content/landing?utm_source=googleShow different CTA based on source
URL-driven tabs/dashboard?tab=analyticsURL manages UI state for shareability

export const dynamic = 'force-dynamic'

Developer explicitly knows this route always needs request-time rendering:

ScenarioWhy Force Dynamic
Real-time dashboardsStock prices, server monitoring, data changes every second
Live inventory/seatsTicketing systems, can't show stale inventory
Fully personalized home100% based on user history, nothing can be static
Development/debuggingEnsure latest results, avoid cache interference
Runtime env variablesConfig that can only be determined at request time

cookies() / headers()

需要讀取每個使用者當下 request 的資訊才能渲染的場景:

場景為什麼需要範例
登入狀態判斷依據 session cookie 決定顯示什麼「歡迎回來,Crystal」vs「請登入」
語系/地區偏好Accept-Language header 或 locale cookie同一個 /products 頁面,台灣用戶看繁中、日本用戶看日文
A/B Testingcookie 裡存了實驗分組同一頁面對 A 組顯示新版 UI、B 組顯示舊版
權限控制依據 auth token 決定能看到什麼內容管理員看到「刪除」按鈕,一般用戶看不到
深色/淺色模式(server-side)讀 cookie 裡的 theme 偏好避免閃爍用 cookie 而非 localStorage 來避免 FOUC

searchParams

URL query string 本質上是每次 request 都可能不同的值,無法 build-time 預渲染:

場景URL 範例為什麼需要
搜尋結果頁/search?q=iphone&sort=price無限種組合,不可能全部預渲染
篩選/過濾/products?category=shoes&color=red同上
分頁/blog?page=3頁碼動態
UTM 追蹤影響內容/landing?utm_source=google根據來源顯示不同 CTA
Tab 切換(URL-driven)/dashboard?tab=analytics用 URL 管理 UI state,方便分享連結

export const dynamic = 'force-dynamic'

開發者明確知道這個 route 永遠需要 request-time 渲染:

場景為什麼要強制 dynamic
即時資料儀表板股價、伺服器監控面板,資料每秒都在變
即時庫存/座位訂票系統,不能讓用戶看到過時的庫存數量
個人化首頁100% 根據用戶歷史行為推薦,沒有任何可以 static 的部分
開發/除錯用途開發階段想確保每次都拿到最新結果,避免快取干擾
依賴 runtime 環境變數需要在 request time 才能確定的環境設定

Appendix B: ASG Multi-Instance Remote Cache

附錄 B:ASG 多 Instance 下的 Remote Cache

The default use cache is in-memory LRU cache, independent per Node.js process. In an ASG multi-instance setup:

                    Load Balancer
                    ┌────┼────┐
                    ▼    ▼    ▼
              ┌─────┐ ┌─────┐ ┌─────┐
              │ EC2 │ │ EC2 │ │ EC2 │
              │ ①   │ │ ②   │ │ ③   │
              │cache│ │cache│ │cache│  ← Three independent in-memory caches
              └─────┘ └─────┘ └─────┘

This causes three problems:

ProblemDescription
Cache inconsistencyUser A hits EC2① sees old data, User B hits EC2② sees new data
Resource wasteThree instances each fetch the same data, backend API load ×3
Revalidation can't syncrevalidateTag('posts') on EC2① only clears ①'s cache, ②③ still stale

Solution: cacheHandlers + external shared cache

Next.js 16 provides cacheHandlers config, combined with use cache: remote:

                    Load Balancer
                    ┌────┼────┐
                    ▼    ▼    ▼
              ┌─────┐ ┌─────┐ ┌─────┐
              │ EC2 │ │ EC2 │ │ EC2 │
              └──┬──┘ └──┬──┘ └──┬──┘
                 │       │       │
                 └───────┼───────┘

                 ┌──────────────┐
                 │ Redis/Valkey │  ← Shared cache
                 │ or DynamoDB  │
                 └──────────────┘
ts
// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheHandlers: {
    default: require.resolve('./cache-handler.js'),
    remote: require.resolve('./remote-cache-handler.js'),
  },
}
ts
// Local in-memory (suitable for high-frequency access, acceptable inconsistency)
async function getNavItems() {
  'use cache'
  // ...
}

// Remote shared cache (needed for ASG scenarios)
async function getKPIData(accountId: string) {
  'use cache: remote'
  cacheLife('minutes')
  cacheTag(`kpi-${accountId}`)
  // ...
}

This is essentially what Vercel does on their platform — their Data Cache is a managed remote cache. Self-hosting on ASG means building this layer yourself.

預設的 use cachein-memory LRU cache,每個 Node.js process 各自獨立。在 ASG 多台 instance 的架構下:

                    Load Balancer
                    ┌────┼────┐
                    ▼    ▼    ▼
              ┌─────┐ ┌─────┐ ┌─────┐
              │ EC2 │ │ EC2 │ │ EC2 │
              │ ①   │ │ ②   │ │ ③   │
              │cache│ │cache│ │cache│  ← 三份各自獨立的 in-memory cache
              └─────┘ └─────┘ └─────┘

這會造成三個問題:

問題說明
快取不一致用戶 A 打到 EC2① 看舊資料,用戶 B 打到 EC2② 看新資料
資源浪費三台各自 fetch 同一筆資料、各自快取,後端 API 負載 ×3
Revalidation 無法同步在 EC2① 上 revalidateTag('posts') 只清了 ① 的快取,②③ 還是舊的

解法:cacheHandlers + 外部共享快取

Next.js 16 提供了 cacheHandlers 設定,搭配 use cache: remote

                    Load Balancer
                    ┌────┼────┐
                    ▼    ▼    ▼
              ┌─────┐ ┌─────┐ ┌─────┐
              │ EC2 │ │ EC2 │ │ EC2 │
              └──┬──┘ └──┬──┘ └──┬──┘
                 │       │       │
                 └───────┼───────┘

                 ┌──────────────┐
                 │ Redis/Valkey │  ← 共享快取
                 │ or DynamoDB  │
                 └──────────────┘
ts
// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheHandlers: {
    default: require.resolve('./cache-handler.js'),
    remote: require.resolve('./remote-cache-handler.js'),
  },
}
ts
// 本地 in-memory(適合極高頻存取、可接受不一致)
async function getNavItems() {
  'use cache'
  // ...
}

// 遠端共享快取(ASG 場景需要這個)
async function getKPIData(accountId: string) {
  'use cache: remote'
  cacheLife('minutes')
  cacheTag(`kpi-${accountId}`)
  // ...
}

這本質上就是 Vercel 在平台上幫你做的事——他們的 Data Cache 就是一個 managed 的 remote cache。Self-host 在 ASG 上,得自己搭這一層。