The Secret Life of a Next.js Request
The Secret Life of a Next.js Request
A complete architectural walkthrough of what happens from browser request to response — middleware, edge runtime, RSC payload, streaming, caching layers, and where your code actually runs.
The Illusion of Simplicity
You write a page component. You deploy to Vercel. Users visit your site. It works.
But between the browser's fetch and your component's render, there's a complex orchestration of runtimes, caches, serialization protocols, and streaming mechanisms. Understanding this architecture isn't academic — it's the difference between a 200ms page load and a 2000ms one.
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE 30,000-FOOT VIEW │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Browser │
│ │ │
│ ▼ │
│ CDN Edge (cache check) ────────────────────────────────┐ │
│ │ │ HIT: return │
│ │ MISS │ cached response │
│ ▼ │ │
│ Middleware (Edge Runtime) ─────────────────────────────┼──▶ redirect/ │
│ │ │ rewrite │
│ │ continue │ │
│ ▼ │ │
│ Route Handler / Page │ │
│ │ │ │
│ ├── Static? ───────▶ Serve from CDN │ │
│ │ │ │
│ ├── ISR? ──────────▶ Check stale, maybe regenerate │ │
│ │ │ │
│ └── Dynamic? ──────▶ Server render (Node.js) │ │
│ │ │ │
│ ▼ │ │
│ RSC Payload Generation │ │
│ │ │ │
│ ▼ │ │
│ Stream HTML + RSC Payload ◀────────────────────────┘ │
│ │ │
│ ▼ │
│ Browser: Parse, Hydrate, Interactive │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Let's trace a request through every layer.
Phase 1: The Browser Makes a Request
Initial Navigation
// User clicks a link or types URL
// Browser initiates navigation
GET /dashboard HTTP/2
Host: app.example.com
Accept: text/html,application/xhtml+xml,...
Accept-Encoding: gzip, br
Cookie: session=abc123
For initial page loads, the browser requests HTML. This is the full page load path.
Client-Side Navigation (Soft Navigation)
// User clicks <Link> component
// Next.js Router intercepts
// Instead of full HTML, browser requests RSC payload
GET /dashboard HTTP/2
Host: app.example.com
Accept: text/x-component // RSC payload format
RSC: 1 // Signal: this is RSC request
Next-Router-State-Tree: [encoded] // Current router state
Next-Router-Prefetch: 1 // If prefetching
The RSC: 1 header tells the server: "Don't send HTML, send the React Server Component payload."
┌─────────────────────────────────────────────────────────────────────────────┐
│ TWO REQUEST TYPES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ INITIAL LOAD (hard navigation): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Request: GET /dashboard (Accept: text/html) │ │
│ │ Response: Full HTML document │ │
│ │ <!DOCTYPE html> │ │
│ │ <html> │ │
│ │ <head>...</head> │ │
│ │ <body> │ │
│ │ <div id="__next">...rendered HTML...</div> │ │
│ │ <script>...RSC payload embedded...</script> │ │
│ │ <script src="/_next/static/chunks/..."/> │ │
│ │ </body> │ │
│ │ </html> │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ CLIENT NAVIGATION (soft navigation): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Request: GET /dashboard (RSC: 1) │ │
│ │ Response: RSC payload only (streamed) │ │
│ │ 0:["$","div",null,{"children":...}] │ │
│ │ 1:["$","$L2",null,{"data":...}] │ │
│ │ 2:I["./components/Chart.js",...] │ │
│ │ ... │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Phase 2: CDN Edge Layer
The request first hits the CDN edge (Cloudflare, Vercel Edge Network, AWS CloudFront).
Cache Lookup
┌─────────────────────────────────────────────────────────────────────────────┐
│ CDN CACHE DECISION TREE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Request arrives at edge │
│ │ │
│ ▼ │
│ Is route static? (pre-rendered at build) │
│ │ │
│ ├── Yes ──▶ Check CDN cache │
│ │ │ │
│ │ ├── HIT ──▶ Return cached HTML (< 10ms) │
│ │ │ │
│ │ └── MISS ──▶ Fetch from origin, cache, return │
│ │ │
│ └── No (dynamic) ──▶ Pass to origin │
│ │ │
│ ▼ │
│ Is ISR route? │
│ │ │
│ ├── Yes ──▶ Check stale-while-revalidate │
│ │ │ │
│ │ ├── Fresh ──▶ Return cached │
│ │ │ │
│ │ └── Stale ──▶ Return cached, │
│ │ trigger background │
│ │ regeneration │
│ │ │
│ └── No ──▶ Execute middleware + render │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Cache Headers That Matter
// next.config.js controls default caching
// But individual routes can override
// Static page (default)
Cache-Control: public, max-age=31536000, immutable
// ISR page
Cache-Control: s-maxage=60, stale-while-revalidate
// Dynamic page (no cache)
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
// API route with revalidation
Cache-Control: s-maxage=3600, stale-while-revalidate=86400
Phase 3: Middleware Execution
If the request passes the CDN cache (miss or dynamic), middleware executes at the edge.
Where Middleware Runs
┌─────────────────────────────────────────────────────────────────────────────┐
│ MIDDLEWARE EXECUTION CONTEXT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ EDGE RUNTIME (V8 Isolates): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • Runs in Cloudflare Workers / Vercel Edge Functions │ │
│ │ • Globally distributed (runs near user) │ │
│ │ • Cold start: ~0ms (V8 isolates, not containers) │ │
│ │ • Max execution: 30 seconds (varies by platform) │ │
│ │ • No Node.js APIs (no fs, no child_process) │ │
│ │ • Limited to Web APIs (fetch, crypto, TextEncoder, etc.) │ │
│ │ • Can't import Node.js packages │ │
│ │ • Memory: ~128MB │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Available APIs: │
│ ├── Request / Response (standard Web API) │
│ ├── fetch() (native, not node-fetch) │
│ ├── crypto.subtle (WebCrypto) │
│ ├── TextEncoder / TextDecoder │
│ ├── URL / URLSearchParams │
│ ├── Headers │
│ ├── cookies() — Next.js helper │
│ ├── headers() — Next.js helper │
│ └── NextResponse — redirect, rewrite, json, next() │
│ │
│ NOT Available: │
│ ├── fs (filesystem) │
│ ├── path │
│ ├── child_process │
│ ├── Most npm packages (if they use Node.js APIs) │
│ └── Long-running connections (WebSockets terminate differently) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Middleware Execution Flow
// middleware.ts (runs on EVERY request by default)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 1. Request arrives — you have full access to:
const url = request.nextUrl; // Parsed URL
const cookies = request.cookies; // Cookie jar
const headers = request.headers; // Request headers
const geo = request.geo; // Geolocation (Vercel)
const ip = request.ip; // Client IP (Vercel)
// 2. Make decisions
const session = cookies.get('session');
if (!session && url.pathname.startsWith('/dashboard')) {
// Redirect: returns 307, browser makes new request
return NextResponse.redirect(new URL('/login', request.url));
}
// 3. Rewrite (internal routing change)
if (url.pathname === '/old-page') {
// URL stays /old-page, but /new-page is rendered
return NextResponse.rewrite(new URL('/new-page', request.url));
}
// 4. Modify request before it reaches route
const response = NextResponse.next();
// Add headers that route handlers will see
response.headers.set('x-user-country', geo?.country ?? 'unknown');
// 5. Modify response on the way back
response.cookies.set('visited', 'true', { httpOnly: true });
return response;
}
// CRITICAL: Configure which routes middleware runs on
export const config = {
matcher: [
// Match all except static files and api routes you want to skip
'/((?!_next/static|_next/image|favicon.ico|api/health).*)',
],
};
Middleware Timing Impact
┌─────────────────────────────────────────────────────────────────────────────┐
│ MIDDLEWARE LATENCY │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Middleware adds latency to EVERY matched request. │
│ │
│ Operation Typical Latency │
│ ───────────────────────────────────────────────────────────────────────── │
│ Cookie/header inspection <1ms │
│ Simple redirect/rewrite <1ms │
│ JWT verification (edge) 1-5ms │
│ KV/Redis lookup (same region) 5-20ms │
│ External API call 50-500ms ⚠️ │
│ Database query Not available (Node.js only) │
│ │
│ Best practices: │
│ • Keep middleware fast (<10ms) │
│ • Avoid external calls if possible │
│ • Use edge-compatible auth (JWT, not session DB lookup) │
│ • Narrow matcher to only routes that need middleware │
│ │
│ If you need database access in auth: │
│ → Don't do it in middleware │
│ → Do it in a Server Component or API route (Node.js runtime) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Phase 4: Route Resolution
After middleware, Next.js resolves which handler should process the request.
Route Matching Order
┌─────────────────────────────────────────────────────────────────────────────┐
│ ROUTE RESOLUTION PRIORITY │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Request: GET /dashboard/settings/profile │
│ │
│ Next.js checks (in order): │
│ │
│ 1. Static files: /public/dashboard/settings/profile (if exists) │
│ │
│ 2. Exact match: /app/dashboard/settings/profile/page.tsx │
│ │
│ 3. Dynamic segments: /app/dashboard/settings/[section]/page.tsx │
│ where section = "profile" │
│ │
│ 4. Catch-all: /app/dashboard/[...slug]/page.tsx │
│ where slug = ["settings", "profile"] │
│ │
│ 5. Optional catch-all: /app/dashboard/[[...slug]]/page.tsx │
│ where slug = ["settings", "profile"] │
│ │
│ 6. Not found: Return 404 │
│ │
│ Priority rules: │
│ • More specific routes win over less specific │
│ • Static segments beat dynamic segments │
│ • Single dynamic beats catch-all │
│ • Catch-all beats optional catch-all │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Parallel and Intercepting Routes
┌─────────────────────────────────────────────────────────────────────────────┐
│ ADVANCED ROUTING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ PARALLEL ROUTES (@folder): │
│ ───────────────────────── │
│ /app/dashboard/ │
│ ├── layout.tsx │
│ ├── page.tsx │
│ ├── @analytics/page.tsx ──┐ │
│ └── @notifications/page.tsx ─┴──▶ Both render in same layout │
│ │
│ // layout.tsx │
│ export default function Layout({ │
│ children, │
│ analytics, // @analytics slot │
│ notifications, // @notifications slot │
│ }) { │
│ return ( │
│ <div> │
│ <main>{children}</main> │
│ <aside>{analytics}</aside> │
│ <aside>{notifications}</aside> │
│ </div> │
│ ); │
│ } │
│ │
│ INTERCEPTING ROUTES ((..)folder): │
│ ───────────────────────────────── │
│ Soft navigation intercepts, hard navigation doesn't │
│ │
│ /app/ │
│ ├── photos/ │
│ │ └── [id]/page.tsx ──▶ Full photo page (hard nav) │
│ └── @modal/ │
│ └── (..)photos/[id]/page.tsx ──▶ Modal (soft nav) │
│ │
│ Click <Link href="/photos/123"> │
│ → Intercept: show modal │
│ Direct URL /photos/123 │
│ → No intercept: show full page │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Phase 5: Server-Side Rendering
The resolved route now executes. Here's where it gets interesting.
Runtime Selection
// Route Segment Config — choose your runtime
// app/dashboard/page.tsx
export const runtime = 'nodejs'; // Default — full Node.js
// OR
export const runtime = 'edge'; // Edge Runtime — limited but fast
// Other segment configs
export const dynamic = 'force-dynamic'; // Never cache
export const dynamic = 'force-static'; // Always static
export const dynamic = 'auto'; // Let Next.js decide (default)
export const revalidate = 60; // ISR: revalidate every 60s
export const revalidate = false; // Never revalidate (static)
export const revalidate = 0; // Always revalidate (dynamic)
export const fetchCache = 'force-cache'; // Cache all fetches
export const fetchCache = 'force-no-store'; // Never cache fetches
The Render Tree
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMPONENT TREE EXECUTION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Request: /dashboard/settings │
│ │
│ File structure: │
│ /app │
│ ├── layout.tsx ← Root Layout (Server Component) │
│ └── dashboard │
│ ├── layout.tsx ← Dashboard Layout (Server Component) │
│ └── settings │
│ └── page.tsx ← Settings Page (Server Component) │
│ │
│ Render order (parallel where possible): │
│ │
│ 1. Root Layout renders │
│ ├── Can fetch data │
│ ├── Defines <html>, <body> │
│ └── Renders {children} slot │
│ │
│ 2. Dashboard Layout renders (parallel with siblings) │
│ ├── Can fetch data │
│ ├── Defines dashboard chrome │
│ └── Renders {children} slot │
│ │
│ 3. Settings Page renders │
│ ├── Can fetch data │
│ ├── Can use Suspense for streaming │
│ └── Returns JSX │
│ │
│ IMPORTANT: Layouts DON'T re-render on navigation between child routes │
│ /dashboard/settings → /dashboard/profile │
│ Root Layout: preserved │
│ Dashboard Layout: preserved │
│ Page: re-rendered │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Server Component Execution
// app/dashboard/page.tsx — Server Component (default)
async function DashboardPage() {
// This code runs ONLY on the server
// Never sent to the browser
// Can access server resources directly
// 1. Direct database access (no API needed)
const user = await db.users.findUnique({
where: { id: getCurrentUserId() },
});
// 2. File system access
const config = await fs.readFile('./config.json', 'utf-8');
// 3. Environment variables (server-only)
const apiKey = process.env.SECRET_API_KEY; // Safe — not exposed
// 4. Fetch with automatic caching
const data = await fetch('https://api.example.com/data', {
// Next.js extends fetch with caching options
next: { revalidate: 3600 }, // Cache for 1 hour
});
// 5. Return JSX — serialized to RSC payload
return (
<div>
<h1>Welcome, {user.name}</h1>
<Suspense fallback={<Loading />}>
{/* This can stream in later */}
<SlowComponent />
</Suspense>
{/* Client Component — marked with 'use client' */}
<InteractiveChart data={data} />
</div>
);
}
What Can't Run on the Server
┌─────────────────────────────────────────────────────────────────────────────┐
│ SERVER VS CLIENT CAPABILITIES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SERVER COMPONENTS CAN: SERVER COMPONENTS CANNOT: │
│ ───────────────────── ─────────────────────── │
│ ✓ Access databases directly ✗ Use useState, useEffect │
│ ✓ Read files ✗ Use browser APIs (window, document) │
│ ✓ Use server-only packages ✗ Add event handlers (onClick) │
│ ✓ Keep secrets safe ✗ Use useContext (client context) │
│ ✓ Reduce bundle size ✗ Access refs │
│ ✓ Fetch with automatic caching ✗ Use lifecycle hooks │
│ ✓ Stream with Suspense ✗ Import CSS modules (client only) │
│ │
│ CLIENT COMPONENTS CAN: CLIENT COMPONENTS CANNOT: │
│ ───────────────────── ─────────────────────── │
│ ✓ Use all React hooks ✗ Direct database/file access │
│ ✓ Use browser APIs ✗ Use server-only packages │
│ ✓ Add interactivity ✗ Keep secrets safe │
│ ✓ Use context providers ✗ Import Server Components directly │
│ ✓ Handle events (must pass as children) │
│ ✓ Manage local state │
│ │
│ KEY: Client Components ARE rendered on the server first (SSR), │
│ then hydrated on the client. They run in both places. │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Phase 6: RSC Payload Generation
Server Components don't send their JavaScript to the browser. Instead, they serialize to an RSC payload.
The RSC Wire Format
┌─────────────────────────────────────────────────────────────────────────────┐
│ RSC PAYLOAD FORMAT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ The server renders components and produces a stream like: │
│ │
│ 0:["$","div",null,{"className":"container","children":"$L1"}] │
│ 1:["$","main",null,{"children":["$","$L2",null,{"userId":123}]}] │
│ 2:I["client-component.js","ClientChart",null] │
│ 3:["$","p",null,{"children":"Static text"}] │
│ │
│ Breaking it down: │
│ ───────────────── │
│ │
│ 0: ─ Row ID (references) │
│ ["$","div",...] ─ Encoded React element │
│ "$" ─ React element marker │
│ "div" ─ Element type │
│ null ─ Key │
│ {...} ─ Props │
│ │
│ "$L1" ─ Lazy reference to row 1 (will stream later) │
│ │
│ "I" prefix ─ Client Component import instruction │
│ ["client-component.js","ClientChart",null] │
│ → Import ClientChart from client-component.js │
│ → This component's code IS sent to browser │
│ │
│ Server Component code is NOT in the payload ─ only their output │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Serialization Rules
// What can be passed from Server to Client Components?
// ✓ Serializable primitives
const props = {
string: "hello",
number: 42,
boolean: true,
null: null,
undefined: undefined,
bigint: 100n,
date: new Date(), // Serialized specially
array: [1, 2, 3],
object: { nested: "ok" },
};
// ✓ React elements (including Server Components as children)
<ClientComponent>
<ServerComponent /> {/* Rendered, serialized, passed as children */}
</ClientComponent>
// ✗ Functions (cannot be serialized)
<ClientComponent onClick={() => {}} /> // ERROR
// ✗ Classes, Symbols, other non-serializable
<ClientComponent data={new Map()} /> // ERROR
// ✗ Circular references
const obj = { self: null };
obj.self = obj;
<ClientComponent data={obj} /> // ERROR
// EXCEPTION: Server Actions (functions that run on server)
// These ARE allowed because they're actually RPC references
<ClientComponent action={serverAction} /> // OK — action runs on server
Phase 7: Streaming and Suspense
How Streaming Works
┌─────────────────────────────────────────────────────────────────────────────┐
│ STREAMING TIMELINE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Without Streaming (traditional SSR): │
│ ─────────────────────────────────── │
│ t=0 Server starts rendering │
│ t=500ms Waiting for slow database query... │
│ t=2000ms Slow query completes │
│ t=2100ms Entire HTML sent to browser │
│ t=2200ms Browser starts parsing │
│ │
│ User sees NOTHING for 2+ seconds │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ With Streaming (RSC + Suspense): │
│ ──────────────────────────────── │
│ t=0 Server starts rendering │
│ t=50ms Shell HTML sent (layout, loading states) │
│ t=100ms Browser starts painting! │
│ t=500ms First chunk streams (above-fold content) │
│ t=2000ms Slow query completes, chunk streams │
│ t=2050ms Final chunk, hydration completes │
│ │
│ User sees content at 100ms, progressive enhancement │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Implementing Streaming
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default async function Dashboard() {
// Fast data — renders immediately
const user = await getUser(); // 50ms
return (
<div>
{/* Streams immediately */}
<Header user={user} />
{/* Streams when ready — shows loading first */}
<Suspense fallback={<CardSkeleton />}>
<RevenueCard /> {/* 200ms fetch */}
</Suspense>
{/* Independent stream — doesn't block RevenueCard */}
<Suspense fallback={<TableSkeleton />}>
<TransactionsTable /> {/* 800ms fetch */}
</Suspense>
{/* Nested streaming */}
<Suspense fallback={<ChartLoading />}>
<AnalyticsSection>
<Suspense fallback={<MiniChartLoading />}>
<ConversionChart /> {/* 1500ms fetch */}
</Suspense>
</AnalyticsSection>
</Suspense>
</div>
);
}
// Each Suspense boundary streams independently
// Chunks arrive as they complete, not in order
The Actual HTTP Response
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
<!-- First chunk: immediate -->
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<div id="__next">
<header>Welcome, John</header>
<div id="revenue-loading">Loading revenue...</div>
<div id="transactions-loading">Loading transactions...</div>
</div>
<script>self.__next_f.push([0])</script>
<script>self.__next_f.push([1,"0:[\"$\",\"div\"..."])</script>
<!-- Second chunk: RevenueCard ready (200ms later) -->
<script>self.__next_f.push([1,"1:[\"$\",\"$L5\"...revenue data..."])</script>
<script>$RC=function(b,c){...};$RC("revenue-loading","revenue-content")</script>
<div hidden id="revenue-content">
<div class="card">Revenue: $50,000</div>
</div>
<!-- Third chunk: TransactionsTable ready (600ms later) -->
<script>self.__next_f.push([1,"2:[\"$\",\"$L8\"...table data..."])</script>
<script>$RC("transactions-loading","transactions-content")</script>
<div hidden id="transactions-content">
<table>...</table>
</div>
<!-- Final chunk: ConversionChart ready (700ms later) -->
<script>self.__next_f.push([1,"3:[\"$\",\"$L12\"...chart data..."])</script>
<script>$RC("chart-loading","chart-content")</script>
<div hidden id="chart-content">
<canvas>...</canvas>
</div>
</body>
</html>
The $RC function is React's "replace content" — it swaps the loading skeleton with the real content without a full re-render.
Phase 8: The Caching Layers
Next.js has four distinct caching layers. Understanding them prevents 90% of "why isn't my data updating" issues.
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS CACHING ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: REQUEST MEMOIZATION (per-request) │
│ ─────────────────────────────────────────── │
│ • Dedupes identical fetch calls in a single render │
│ • Only during initial server render │
│ • Same URL + options = one actual fetch │
│ │
│ // These make ONE network request │
│ async function ComponentA() { │
│ const data = await fetch('/api/user'); // Request 1 │
│ } │
│ async function ComponentB() { │
│ const data = await fetch('/api/user'); // Reuses Request 1 │
│ } │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ Layer 2: DATA CACHE (cross-request, server-side) │
│ ───────────────────────────────────────────────── │
│ • Persists fetch results across requests and deploys │
│ • Controlled by fetch options: cache, next.revalidate │
│ • Stored in: Vercel Data Cache, or file system (self-hosted) │
│ │
│ await fetch('/api/data', { │
│ cache: 'force-cache', // Use cache (default) │
│ // OR │
│ cache: 'no-store', // Skip cache │
│ // OR │
│ next: { revalidate: 3600 }, // Cache for 1 hour │
│ }); │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ Layer 3: FULL ROUTE CACHE (pre-rendered pages) │
│ ─────────────────────────────────────────────── │
│ • Caches the RSC payload + HTML at build time │
│ • For static and ISR pages │
│ • Invalidated by: revalidatePath(), revalidateTag(), redeploy │
│ │
│ Page type Full Route Cache behavior │
│ ─────────────────────────────────────────── │
│ Static Cached at build, never invalidates │
│ ISR Cached, revalidates per revalidate config │
│ Dynamic Not cached (rendered per request) │
│ │
│ ───────────────────────────────────────────────────────────────────────── │
│ │
│ Layer 4: ROUTER CACHE (client-side) │
│ ───────────────────────────────────── │
│ • Browser memory cache of RSC payloads │
│ • Enables instant back/forward navigation │
│ • Prefetched routes cached for 30 seconds (static) or 0 (dynamic) │
│ • Full page navigations cached for 5 minutes │
│ • Invalidated by: router.refresh(), revalidatePath(), cookies change │
│ │
│ ⚠️ This is the most confusing cache — it's why you sometimes see │
│ stale data after mutations until you hard refresh │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Cache Invalidation
// Server Action: Invalidate caches after mutation
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updatePost(postId: string, data: PostData) {
// 1. Perform mutation
await db.posts.update({ where: { id: postId }, data });
// 2. Invalidate Data Cache entries with this tag
revalidateTag('posts'); // All fetches tagged 'posts'
revalidateTag(`post-${postId}`); // Specific post
// 3. Invalidate Full Route Cache for these paths
revalidatePath('/blog'); // Blog listing
revalidatePath(`/blog/${postId}`); // Specific post page
revalidatePath('/blog/[slug]', 'page'); // All dynamic blog pages
// 4. Router Cache automatically invalidated when:
// - revalidatePath is called
// - cookies are set/deleted
// - router.refresh() called on client
}
// Tagging fetches for granular invalidation
async function getPost(id: string) {
const res = await fetch(`/api/posts/${id}`, {
next: {
tags: ['posts', `post-${id}`], // Multiple tags
revalidate: 3600,
},
});
return res.json();
}
Phase 9: Client-Side Hydration
The HTML arrives. Now the browser takes over.
Hydration Process
┌─────────────────────────────────────────────────────────────────────────────┐
│ HYDRATION TIMELINE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ t=0ms HTML arrives, browser starts parsing │
│ t=50ms First paint (HTML visible, not interactive) │
│ t=100ms CSS loaded, styled paint │
│ t=200ms React runtime JS loaded │
│ t=250ms RSC payload parsed │
│ t=300ms Hydration begins │
│ • React walks the DOM │
│ • Attaches event handlers │
│ • Connects to state │
│ t=400ms Hydration complete — page is interactive │
│ │
│ SELECTIVE HYDRATION (with Suspense): │
│ ───────────────────────────────────── │
│ t=0ms HTML arrives with loading skeletons │
│ t=50ms First paint (shell visible) │
│ t=200ms React runtime loaded │
│ t=250ms Hydrate what's available (header, nav) │
│ User can interact with hydrated parts! │
│ t=400ms Slow chunk streams in │
│ t=450ms Hydrate the new chunk │
│ │
│ User can interact BEFORE full hydration completes │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
What Gets Sent to the Browser
┌─────────────────────────────────────────────────────────────────────────────┐
│ BUNDLE ANALYSIS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SENT TO BROWSER: │
│ ───────────────── │
│ ✓ HTML (initial render) │
│ ✓ RSC Payload (serialized component tree) │
│ ✓ Client Component JavaScript │
│ ✓ React runtime │
│ ✓ Next.js client runtime │
│ ✓ CSS (modules, global) │
│ │
│ NOT SENT: │
│ ────────── │
│ ✗ Server Component JavaScript │
│ ✗ Server-only dependencies │
│ ✗ Database clients │
│ ✗ File system code │
│ ✗ Environment secrets │
│ │
│ Example bundle impact: │
│ ────────────────────── │
│ Server Component using: Not in bundle │
│ - prisma (45KB) ✗ │
│ - lodash (70KB) ✗ │
│ - date-fns (20KB) ✗ │
│ │
│ Client Component using: In bundle │
│ - chart.js (60KB) ✓ │
│ - framer-motion (40KB) ✓ │
│ │
│ Server Components keep your bundle small! │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Complete Picture
┌─────────────────────────────────────────────────────────────────────────────┐
│ END-TO-END REQUEST LIFECYCLE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. BROWSER │
│ └──▶ GET /dashboard │
│ │
│ 2. CDN EDGE │
│ ├── Cache HIT? ──▶ Return cached (10ms) │
│ └── Cache MISS ──▶ Continue to origin │
│ │
│ 3. MIDDLEWARE (Edge Runtime) │
│ ├── Check auth, geolocation, A/B tests │
│ ├── Redirect? ──▶ Return 307 │
│ ├── Rewrite? ──▶ Change internal routing │
│ └── Continue ──▶ Pass to route handler │
│ │
│ 4. ROUTE RESOLUTION │
│ └──▶ /app/dashboard/page.tsx │
│ │
│ 5. SERVER RENDER (Node.js or Edge) │
│ ├── Execute Server Components │
│ │ ├── Fetch data (with caching) │
│ │ ├── Access database │
│ │ └── Generate JSX │
│ ├── Pre-render Client Components (SSR) │
│ └── Generate RSC payload │
│ │
│ 6. STREAMING │
│ ├── Send HTML shell + first chunks │
│ ├── Stream Suspense boundaries as they resolve │
│ └── Complete when all boundaries resolve │
│ │
│ 7. BROWSER RECEIVES │
│ ├── Parse HTML ──▶ First paint │
│ ├── Load React + Client Components JS │
│ ├── Parse RSC payload │
│ └── Hydrate ──▶ Interactive │
│ │
│ 8. SUBSEQUENT NAVIGATIONS │
│ ├── <Link> click ──▶ RSC request (not full HTML) │
│ ├── Prefetch on hover/viewport ──▶ Cache in Router Cache │
│ └── Server Action ──▶ POST to server, revalidate caches │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Debugging: Where Is My Code Running?
// Add this to any file to see where it runs
export function whereAmI() {
const info = {
isServer: typeof window === 'undefined',
isEdge: typeof EdgeRuntime !== 'undefined',
isNode: typeof process !== 'undefined' && process.versions?.node,
isBrowser: typeof window !== 'undefined',
};
if (info.isEdge) return 'Edge Runtime';
if (info.isNode && info.isServer) return 'Node.js Server';
if (info.isBrowser) return 'Browser';
return 'Unknown';
}
// In a Server Component
async function ServerComponent() {
console.log('Rendering on:', whereAmI()); // "Node.js Server"
return <div>Hello</div>;
}
// In a Client Component
'use client';
function ClientComponent() {
console.log('Rendering on:', whereAmI());
// First log: "Node.js Server" (SSR)
// Second log: "Browser" (hydration)
return <div>Hello</div>;
}
// In Middleware
export function middleware() {
console.log('Running on:', whereAmI()); // "Edge Runtime"
return NextResponse.next();
}
Common "Where Does This Run?" Questions
┌─────────────────────────────────────────────────────────────────────────────┐
│ CODE LOCATION REFERENCE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Code Runs On │
│ ───────────────────────────────────────────────────────────────────────── │
│ middleware.ts Edge Runtime (always) │
│ Server Component Node.js (default) or Edge │
│ Client Component (initial) Node.js (SSR) + Browser (hydration)│
│ Client Component (navigation) Browser only │
│ 'use server' action Node.js (default) or Edge │
│ route.ts (Route Handler) Node.js (default) or Edge │
│ generateStaticParams Node.js at build time │
│ generateMetadata Node.js at request/build time │
│ next.config.js Node.js at build time │
│ instrumentation.ts Node.js at startup │
│ │
│ Runtime selection: │
│ ────────────────── │
│ export const runtime = 'edge'; // Forces Edge Runtime │
│ export const runtime = 'nodejs'; // Forces Node.js (default) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Performance Implications
┌─────────────────────────────────────────────────────────────────────────────┐
│ ARCHITECTURAL PERFORMANCE LEVERS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ LEVER IMPACT HOW │
│ ───────────────────────────────────────────────────────────────────────── │
│ Static generation Fastest export const dynamic = │
│ (CDN cached) 'force-static' │
│ │
│ ISR Fast export const revalidate │
│ (CDN + fresh) = 60 │
│ │
│ Streaming Fast TTFB Use Suspense boundaries │
│ (progressive) │
│ │
│ Edge Runtime Lower latency export const runtime = │
│ (near user) 'edge' │
│ │
│ Data caching Fewer fetches fetch next: { revalidate }│
│ │
│ Request memoization Fewer fetches Automatic for same URL │
│ │
│ Server Components Smaller bundles Default — avoid 'use │
│ client' where possible │
│ │
│ Parallel data fetching Lower latency Promise.all([ │
│ fetch1(), │
│ fetch2(), │
│ ]) │
│ │
│ Prefetching Instant nav <Link> auto-prefetches │
│ │
│ ANTI-PATTERNS: │
│ ────────────── │
│ ✗ Dynamic when static works Slower Check if data changes │
│ ✗ Edge when you need Node Errors Know runtime limits │
│ ✗ Blocking waterfall fetches Slow render Use parallel + Suspense │
│ ✗ Client Components for static Bigger bundle Default to Server │
│ ✗ No-cache everything Slow + expensive Use appropriate caching │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Summary
A Next.js request isn't a simple HTTP round-trip. It's an orchestrated flow through:
- CDN Edge — Cache lookup, geographic routing
- Middleware — Edge-based request interception
- Route Resolution — File-system based matching
- Server Rendering — Components execute, data fetches
- RSC Serialization — Tree converted to wire format
- Streaming — Progressive HTML delivery
- Caching — Four layers, each with different semantics
- Hydration — Browser takes over
The architecture is complex because it solves hard problems: global latency, bundle size, caching, progressive enhancement. Understanding it means you can optimize for your specific use case — and debug the inevitable "why is this data stale?" issues.
The best Next.js apps are the ones where developers understand which code runs where, which cache affects what, and why their 50ms page became a 5000ms page. This knowledge separates "it works" from "it works well."
What did you think?