1.2 — How pages actually load¶
Module 1 · Lesson 2 · 🟢 Foundational · ~30 min
What you'll learn¶
- The full path from tap to interactive, with realistic timings for a mobile shopper
- Where the main thread becomes the bottleneck and why that's different from network
- Where React, RSC, hydration, and Next.js fit into that timeline
- The four things that block rendering, and the four that block interaction
You cannot debug what you can't picture. This lesson is the mental model every later diagnosis hangs off.
The timeline, end to end¶
A shopper taps a Google result for an Aurora Market PDP on a mid‑tier Android over 4G. Here's the full sequence, with realistic durations for an unoptimized site:
t=0ms Tap
├─ 0–120 DNS lookup (cold) ← network
├─ 120–200 TCP connect ← network (0-RTT with H3/QUIC)
├─ 200–340 TLS handshake ← network
├─ 340–360 Request sent
├─ 360–1270 Server thinks ────────────────────────────┐
│ CDN miss → middleware 40ms │ TTFB = 1270ms
│ → RSC render + 6 sequential BFF calls │
│ → 830ms server time ─┘
├─ 1270 First byte / streaming HTML starts
├─ 1270–1600 HTML streams in; parser discovers subresources
│ ├─ CSS (render-blocking) fetched 1310→1720
│ ├─ JS <script> tags queued
│ └─ Hero <img> discovered at 1450 (too late — see below)
├─ 1720 First Contentful Paint (CSS arrived, text paints)
├─ 1450–2900 Hero image: connect to img CDN (new origin!) 180ms
│ + download 340KB over 4G ≈ 1270ms
├─ 2900 Largest Contentful Paint 🔴
├─ 1800–4100 JS: 734KB compressed → ~2.6MB parsed
│ ├─ parse + compile ~480ms ← main thread, blocking
│ ├─ execute module init ~390ms ← main thread, blocking
│ ├─ hydration ~610ms ← main thread, blocking
│ └─ third-party tag soup ~900ms ← main thread, blocking
├─ 4100 Page is genuinely interactive
└─ 4100–5200 Late CMS banner injects → layout shift 🔴 CLS 0.18
Observation: only about 1.3 s of that 4.1 s is network transfer. The rest is server thinking and main‑thread work. On mid‑tier mobile, CPU is usually the binding constraint — which is why "just add a CDN" doesn't fix a React commerce site.
Phase 1 — Connection setup (0–360 ms)¶
| Step | Cost | How to cut it |
|---|---|---|
| DNS | 20–150 ms | dns-prefetch, fewer distinct origins, DNS with good anycast |
| TCP | 1 RTT | HTTP/3 (QUIC) folds this in |
| TLS | 1–2 RTT | TLS 1.3 (1‑RTT), session resumption (0‑RTT), HTTP/3 |
Each additional origin you touch pays this again. An Aurora PDP touches 14 origins: your domain, image CDN, font host (if not self‑hosted), tag manager, analytics, A/B tool, chat, reviews, two ad networks, session replay, consent, payments, and a "recently viewed" SDK.
That's up to ~11 extra connection setups, many of which happen during the critical path.
<!-- In app/layout.tsx <head>: warm the ones that are truly critical -->
<link rel="preconnect" href="https://images.auroramarket.com" crossOrigin="" />
<link rel="preconnect" href="https://api.auroramarket.com" />
<!-- Cheaper hint for origins needed later, not now -->
<link rel="dns-prefetch" href="https://reviews.example-vendor.com" />
Limit yourself to 2–4
preconnects. Each one costs a connection you might not use, and browsers cap parallel connection setup. Preconnecting to ten origins is slower than three. Details: 2.4 Delivery.
Phase 2 — Server time (TTFB)¶
TTFB is network latency + server processing. You control the second part, and on a badly
architected Next.js route it dominates.
// ❌ The Aurora "before": six sequential awaits = sum of all latencies
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // 180ms
const inventory = await getInventory(product.id); // 140ms
const price = await getPrice(product.id); // 120ms
const reviews = await getReviews(product.id); // 260ms
const recs = await getRecommendations(product.id); // 310ms ← slow, not needed for LCP
const content = await getCmsBlocks(product.category); // 190ms
// 830ms of server time before a single byte ships
}
// ✅ Parallel what's needed for the shell; stream what isn't
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await getProduct(slug); // 180ms — needed to render anything
// Fire in parallel; only await what the above-the-fold shell needs
const [inventory, price] = await Promise.all([
getInventory(product.id),
getPrice(product.id),
]); // 140ms (max, not sum)
return (
<>
<ProductHero product={product} price={price} inventory={inventory} />
{/* Below the fold: stream, don't block */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={product.id} />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations productId={product.id} />
</Suspense>
</>
);
}
// Server time before first byte: ~320ms instead of 830ms
Full treatment: 7.1 Waterfalls & API design and 3.3 Streaming & Suspense.
Phase 3 — Parsing, and what blocks rendering¶
The HTML parser builds the DOM as bytes arrive. Four things interrupt it:
1. Render‑blocking CSS. The browser won't paint until CSS in <head> is fetched and parsed.
This is usually correct behavior — you don't want unstyled content — but a 200 KB stylesheet on a
slow connection is a paint delay for the whole page.
2. Parser‑blocking scripts. A plain <script src> in <head> halts HTML parsing entirely
until it downloads and executes. defer waits until parsing finishes; async executes whenever
it arrives (still blocking the main thread when it does).
<script src="tag-manager.js"></script> <!-- ❌ blocks the parser -->
<script src="tag-manager.js" defer></script> <!-- ✅ after parse, in order -->
<script src="analytics.js" async></script> <!-- ⚠️ any time, order not guaranteed -->
3. Synchronous layout work. Late‑injected DOM, web fonts swapping, and JS reading layout
properties (offsetHeight) force recalculation.
4. Resource discovery order. The preload scanner races ahead of the parser to find subresources early. It only sees things in the HTML. An image whose URL is computed in JavaScript is invisible to it — this is the number one cause of slow LCP in React commerce apps:
// ❌ Preload scanner can't see this. Image discovery waits for JS to download,
// parse, execute, and hydrate — often 1.5s+ after HTML arrived.
'use client';
function HeroCarousel({ slides }) {
const [i, setI] = useState(0);
return <img src={slides[i].url} alt={slides[i].alt} />;
}
// ✅ First slide is server-rendered, in the HTML, with priority.
// Subsequent slides can hydrate lazily.
<Image src={slides[0].url} alt={slides[0].alt} priority sizes="100vw" />
Phase 4 — The main thread¶
One thread runs JavaScript, style, layout, paint, and event handlers. Everything queued behind a long task waits, including the tap the user just made.
Main thread on an unoptimized PDP (mid-tier Android, 6× slower than a dev laptop)
│████████ parse+compile 480ms ██│ ← nothing else can happen
│███ module init 390ms ███│
│████ hydration 610ms ████│
│██ GTM 340 ██│
│█ tags █│
User taps "Add to size L" here ─────────────────────────────▲
...handler runs 780ms later. INP: 780ms. 🔴
A long task is any task ≥50 ms. Anything longer makes input feel laggy. React's own work counts: rendering 120 product cards synchronously is a long task, and no amount of network optimization touches it.
Key implication for React apps:
Network optimization improves LCP. Main‑thread optimization improves INP. They are different problems with different fixes, and a team that only knows the first will plateau with a good LCP and a terrible INP. That's the most common profile on large commerce sites today.
Phase 5 — Where React and Next.js fit¶
SERVER CLIENT
────── ──────
Route matched
↓
Middleware runs (every matching request!)
↓
Server Components render
· async/await data fetching
· produces the RSC payload (a serialized tree)
· Client Components become placeholders + module refs
↓
HTML streamed ──────────────────────────────▶ Parse HTML → paint shell (FCP/LCP possible here)
RSC payload streamed inline (self.__next_f) ↓
↓ Download JS chunks for Client Components
Suspense boundaries resolve, more chunks ↓
flush as data arrives ───────────────────────▶ Hydrate: React attaches to existing DOM,
re-runs Client Components, wires event handlers
↓
Page interactive
Three consequences that drive most of Module 3 and 4:
- Server Components ship zero JS to the client. Their output is data, not code. Moving a component from client to server removes its bundle cost and its hydration cost.
- The RSC payload is not free. It's serialized data inlined into the HTML. A PDP that passes a 400 KB product object into a Client Component ships that object twice — once as HTML, once as RSC payload. See 3.2.
'use client'is contagious downward. Everything imported by a Client Component becomes part of the client bundle. One'use client'near the root of the tree turns off RSC for the whole subtree — Aurora's structural problem #2.
Rendering vs interaction: the two budgets¶
| Load (LCP, FCP) | Interaction (INP) | |
|---|---|---|
| Bottleneck | Network + server time | Main thread (CPU) |
| Biggest lever | Fewer/earlier/smaller critical resources | Less JavaScript, less re‑rendering |
| Fixed by | Caching, CDN, image pipeline, streaming | State architecture, concurrency, code splitting |
| Gets worse with | Distance, connection quality | Device age, app complexity, third parties |
| Where in this course | Modules 2, 3, 6.1, 7 | Modules 4, 5, 6.2 |
Teams that treat these as one problem ship a CDN and wonder why the site still feels slow.
Common mistakes¶
| Mistake | Reality |
|---|---|
| "Add a CDN and we're done" | CDN fixes network distance; most React slowness is CPU and server render time |
| Optimizing FCP instead of LCP | FCP can be a skeleton. Users care about the product image |
| Preloading everything | Preload competes for bandwidth; over‑preloading delays the actual LCP element |
| Measuring on a dev laptop | 6× CPU difference means you literally cannot feel the median user's experience |
| Treating hydration as free | It's often the single largest main‑thread block on a commerce page |
| Ignoring the preload scanner | JS‑computed image URLs are the #1 LCP bug in React commerce apps |
Lab 1.2 — Trace your own PDP¶
- Open your slowest page type in Chrome DevTools → Performance.
- Set CPU: 4× slowdown and Network: Slow 4G. Hard‑reload with the recorder running.
- Annotate a copy of the timeline diagram above with your real numbers:
- TTFB, FCP, LCP markers (Timings track)
- Total time in Scripting (yellow) before LCP
- Every task over 50 ms, and which script owns it (Bottom‑Up → group by URL)
- Answer three questions:
- Is LCP network‑bound or main‑thread‑bound? (Is the LCP element downloaded early and painted late, or discovered late?)
- What percentage of pre‑interactive main‑thread time is third‑party?
- When is the LCP image discovered vs requested vs painted?
- Save the trace file. You'll compare against it after Module 2.
Checklist¶
- You can state, for your worst page, whether it's network‑bound or CPU‑bound
- LCP element is present in the initial HTML (visible to the preload scanner)
- Origin count on the critical path counted, with an owner for each
- No parser‑blocking scripts in
<head> - You've traced at 4× CPU throttling at least once and it hurt to watch