Watch one web page load three ways at once
Same product page, same mid-range phone, same 4G connection. Only the rendering strategy changes. Then take the controls and test the other four.
- Sees the product
- 2.9 s
- Can tap the button
- 2.9 s
- Sees the product
- 1.6 s
- Can tap the button
- 4.6 s
- Sees the product
- 289 ms
- Can tap the button
- 1.6 s
Same page, seven ways to build it
Pick a strategy, then change the phone, the network or the page. The waterfall redraws, the mock browser repaints, and you can drag the playhead to any moment.
Server-side rendering, waiting for everything. Render fresh HTML on the server for every request. How it works
- First byteTTFB
- 1.5 s
- First paintFCP
- 1.6 s
- Main contentLCP
- 1.6 s
- InteractiveTTI
- 4.6 s
- Dead zoneLooks ready, ignores taps
- 3.0 s
- JavaScriptShipped and run
- 320 KB
- Nothing leaves the server for 1.5 s. It holds the response until the slowest data (1.2 s) is ready and the page is rendered.
- Every page view costs one server render.
- Dead zone: the main content is visible at 1.6 s but ignores taps until 4.6 s. That is 3.0 s spent hydrating 320 KB on a 4× slower CPU.
- Connect
- Waiting
- HTML
- JavaScript
- Data
- Render
- Parse
- Paint
- Run JS
- Hydrate
Rendered on the origin for every request. Nothing is sent until the slowest data is back.
How the numbers are made
This is a timing model, not a browser. It is built from a handful of rules, and it is meant to move the way real measurements move. Use it to see why one strategy beats another, and run Lighthouse or WebPageTest for a verdict on your own site.
- Network. A cold connection costs two round trips. A request costs one more. Transfer time is size × 8 ÷ bandwidth.
- Device. Running JavaScript costs 1.4 ms per gzipped KB on a fast desktop. Hydration costs 0.8 ms per KB. Both multiply by the CPU slowdown you pick.
- Server. A render takes 20 ms plus 0.4 ms per KB of HTML. Data fetches run in parallel, so the slowest one decides when blocking SSR can answer.
- First paint. The browser paints after the first ~14 KB of HTML, the size of the first TCP congestion window.
- Server Components. The runtime is 45 KB and always ships. The rest of your bundle shrinks to the share you keep on the client, and the flight payload adds 25% to the streamed HTML.
- Interactive. The end of hydration, or the moment the main content arrives, whichever is later.
The rules live in src/engine/simulate.ts. Hundreds of tests check invariants such as “a slower network never makes anything faster”.
The same request, with the work in a different place
Step through what actually happens between a tap on a link and a usable page. Watch which box does the work, and when the visitor gets to see anything.
Ship an empty page and let the browser build it.
The server sends a near-empty HTML file and a JavaScript bundle. The browser runs the bundle, calls your API, then renders. Nothing meaningful is on screen until all three have happened, one after the other.
Reach for it when
- Authenticated dashboards and admin tools
- Highly interactive apps: editors, canvases, maps
- Prototypes and internal tools on plain static hosting
Look elsewhere when
- Public pages that need to rank or unfurl in chat apps
- Landing pages where first paint decides conversion
- Low-end devices on slow networks
Watch out for
- Three sequential waits: HTML, then JS, then data. Preload the data call to break the chain.
- Crawlers that do not run JavaScript see an empty page, and link previews are blank.
- Bundles only grow. Without route splitting, the settings page costs every visitor the whole app.
In code
'use client';
import { use, useEffect, useState } from 'react';
export default function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [product, setProduct] = useState<Product | null>(null);
useEffect(() => {
fetch('/api/products/' + id)
.then((res) => res.json())
.then(setProduct);
}, [id]);
if (!product) return <ProductSkeleton />;
return <ProductView product={product} />;
}Everything runs in the browser. The HTML that reaches the visitor has no product in it.
In an interview“Pick it when the page is behind a login and SEO does not matter, then keep the bundle small and fetch in parallel.”
Side by side
Click a column to sort by it. Hover a row to follow it across.
| Strategy | ||||||||
|---|---|---|---|---|---|---|---|---|
| Strong | Weak | Fair | Strong | Strong | Weak | Strong | Strong | |
| Strong | Strong | Fair | Weak | Weak | Strong | Strong | Strong | |
| Strong | Strong | Fair | Good | Weak | Strong | Strong | Fair | |
| Weak | Fair | Fair | Strong | Strong | Strong | Weak | Fair | |
| Good | Good | Fair | Strong | Strong | Strong | Weak | Fair | |
| Good | Good | Good | Strong | Strong | Strong | Fair | Fair | |
| Strong | Strong | Good | Good | Good | Strong | Good | Weak |
Four filled bars is the best case for that column.
Glossary
- TTFB
- Time to first byte. How long until the first piece of the response arrives. It includes any server work done before sending.
- FCP
- First contentful paint. The first moment anything real is drawn: text, an image, a skeleton.
- LCP
- Largest contentful paint. When the biggest element in the viewport is drawn. Usually the hero image or headline.
- TTI
- Time to interactive. The point where the main content responds to taps. This site measures it as the end of hydration.
- INP
- Interaction to next paint. How long the page takes to react to a tap. Hydration delays the first tap.
- Hydration
- React re-running your components in the browser to attach handlers to HTML that the server already produced.
- Flight payload
- The compact stream that describes the output of Server Components, including references to client components.
- Stale-while-revalidate
- Serve the old copy immediately, refresh it in the background, and let the next visitor have the new one.
- Suspense boundary
- A place where React can show a fallback while a part of the tree waits for data.
- Dead zone
- The time between a page looking ready and actually responding. This lab calls it out because it is the cost of SSR and SSG that is easiest to miss.
Why “revalidate: 60” does not mean fresh every 60 seconds
Run an ISR cache in fast-forward. Publish in the CMS, send visitors, and watch who sees which version, and who triggers the rebuild.
The cache
The CMS
An editor saves a change. The cache does not know yet.
On-demand options a webhook would call. Revalidate serves stale once. Expire makes the next visitor wait.
The visitors
The clock
Virtual time: 0.0 s
- CMS hasv1
- Cache holdsv1fresh, 0.0 s old
- Rebuildidle
- Hit
- Stale
- Miss
- Ring: older than the CMS
- Within the window
- Past the window
- Visitors
- 0
- Answered instantly
- 100%
- Server renders
- 0
- Oldest page shown
- none
Latest visitors
Nobody yet. Press “One visitor arrives”, or turn traffic on.
The three outcomes
Hit The entry is younger than the revalidate window. Served from cache, nothing else happens.
Stale The entry is older than the window. The visitor still gets it instantly, and the first such visit starts one rebuild in the background.
Miss Nothing cached, or it was expired on demand. This one visitor waits for a full render.
What to try
- Publish a version, then send visitors. Count how many see the old one before the cache catches up.
- Drop revalidate to 5 s and set traffic to Spike. Origin renders barely move. Staleness shrinks.
- Press Expire now, then send one visitor. That is the cost of making an editor’s change instant.
- Press Purge with traffic on. One visitor waits, and everyone after them does not.
One slow widget should never hold the whole page hostage
Change how the server sends the same four regions. Wrap the slow ones in Suspense, mark which ones read request data, and watch the HTTP response arrive piece by piece.
The shell goes first. Each Suspense boundary streams in when its data is ready.
- First paint
- 145 ms2.4 s sooner
- Product visible
- 420 ms2.1 s sooner
- Everything visible
- 2.5 s
- Nothing on screen
- Placeholder showing
- Content arrives
The HTTP response, as it arrives
…Shell flushed…Product resolved…Recommended resolved…Reviews resolved
What streaming changes
It changes when the visitor first sees something, not how long the slowest query takes. The reviews still arrive at the same moment. The difference is that they no longer decide when everything else appears.
What it needs from you
Suspense boundaries around every slow region, fallbacks shaped like the final content so nothing jumps, and a host that does not buffer responses. The HTTP status is sent with the first chunk, so a late 404 cannot change it.
What partial prerendering adds
The shell itself no longer waits for an origin trip. Static regions come from the CDN at once, and the origin works on the dynamic holes in parallel from the moment the request arrives.
Where you put “use client” decides what the phone has to run
The product page below is a real-shaped tree. Some components need the browser, some need the server, and the rest could go either way. Move the boundary and watch the bundle respond.
- reads data
- uses state
- has handlers
- uses state
- has handlers
- reads data
- uses state
- uses state
- reads data
- uses state
- reads data
- has handlers
- Runtime, 45 KB
- Needs the browser
- You chose client
- Pulled in by an import
- Client components
- 11
- Server components
- 7
- Run and hydrate, mid-range phone
- 1.5 s of 2.1 s
DetailsTabs
ClientSwitches between Description and Specs. Needs the browser. Uses state, so it must run in the browser.
'use client';
import { useState } from 'react';
export function DetailsTabs() {
const [tab, setTab] = useState('description');
return <div role="tablist">{children}</div>;
}The boundary is an import graph
A client component and everything it imports end up in the bundle. That is why a small tabs component can quietly pull a 35 KB markdown parser to the phone.
Children cross the boundary for free
A server component can render a client component and pass other server components in as children. They arrive already rendered, so their code never ships. Press “Pass as children” on the pulled components above.
Locked means the framework enforces it
A component that calls useState cannot be a server component, and one that reads the database should never be a client one. Mark server code with the server-only package so an import mistake fails the build.
Describe the page and get a defensible answer
Seven questions, the same ones a senior engineer asks before choosing. The ranking updates as you answer, and every call comes with the reasoning.
Defend your choice out loud
12 scenarios of the kind frontend system design rounds use. Pick the strategy, then read why the other three lose. The follow-up question is the one an interviewer asks next.
An internal analytics dashboard
A logged-in tool with live charts, filters and drag-and-drop widgets.
- Behind SSO
- Used on desktops
- Almost every component is interactive