Wiki — CSR vs SSR (split-screen explainer with load race)
A rendering-strategies explainer built as a literal split screen: the left half argues client-side rendering in amber-on-near-black, the right half argues server-side rendering in teal-on-light, joined by a dashed center seam. Every concept band mirrors the same idea on both sides — request waterfalls, hydration, scorecards, SSG/ISR/streaming/islands — and the centerpiece is a Simulate Page Load race where two fake browser windows load the same shop concurrently, lighting TTFB / FCP / TTI lamps as each side hits its milestones.
MCP
Code
:root {
--client-bg: #16120c;
--client-bg2: #1e1810;
--client-ink: #f3e9d8;
--client-soft: #b8a583;
--client-accent: #f5a623;
--client-line: #3a2f1e;
--server-bg: #f2f8f7;
--server-bg2: #e8f2f0;
--server-ink: #10312c;
--server-soft: #4e7a72;
--server-accent: #0d9488;
--server-line: #cfe2de;
--mono: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
--sans: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--sans);
font-size: 15.5px;
line-height: 1.6;
background: #0d0b08;
}
/* ── sticky top bar ─────────────────────── */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.55rem 1.2rem;
background: linear-gradient(90deg, var(--client-bg) 50%, var(--server-bg) 50%);
border-bottom: 1px solid rgba(128, 128, 128, 0.25);
}
.topbar .brand {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--client-accent);
}
.topbar .brand .vs { color: var(--client-soft); }
.topbar .dots {
margin-left: auto;
display: flex;
gap: 0.45rem;
}
.topbar .dots a {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid var(--server-accent);
background: transparent;
display: block;
transition: background 0.15s, transform 0.15s;
}
.topbar .dots a:hover { transform: scale(1.3); }
.topbar .dots a.current { background: var(--server-accent); }
/* ── bands / halves ─────────────────────── */
.band {
display: grid;
grid-template-columns: 1fr 1fr;
position: relative;
}
.band::after {
content: "";
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 2px;
transform: translateX(-50%);
background: repeating-linear-gradient(
180deg,
rgba(128, 128, 128, 0.5) 0 8px,
transparent 8px 16px
);
z-index: 1;
}
.half {
padding: 2.6rem 2.4rem;
}
.half.client {
background: var(--client-bg);
color: var(--client-ink);
}
.band:nth-child(odd) .half.client { background: var(--client-bg2); }
.half.server {
background: var(--server-bg);
color: var(--server-ink);
}
.band:nth-child(odd) .half.server { background: var(--server-bg2); }
.half .tag {
font-family: var(--mono);
font-size: 0.62rem;
letter-spacing: 0.25em;
text-transform: uppercase;
display: inline-block;
padding: 0.25em 0.8em;
border-radius: 3px;
margin-bottom: 0.9rem;
}
.half.client .tag { background: var(--client-accent); color: #16120c; }
.half.server .tag { background: var(--server-accent); color: #f2f8f7; }
.half h2 {
font-size: 1.35rem;
line-height: 1.25;
margin-bottom: 0.6rem;
}
.half h3 {
font-family: var(--mono);
font-size: 0.78rem;
letter-spacing: 0.15em;
text-transform: uppercase;
margin: 1.4rem 0 0.5rem;
}
.half.client h3 { color: var(--client-accent); }
.half.server h3 { color: var(--server-accent); }
.half p { margin: 0.5rem 0; }
.half.client p { color: var(--client-soft); }
.half.server p { color: var(--server-soft); }
.half strong { font-weight: 700; }
.half.client strong { color: var(--client-ink); }
.half.server strong { color: var(--server-ink); }
.half code {
font-family: var(--mono);
font-size: 0.84em;
padding: 0.08em 0.35em;
border-radius: 4px;
}
.half.client code { background: var(--client-line); color: var(--client-accent); }
.half.server code { background: var(--server-line); color: var(--server-accent); }
/* numbered step lists */
.steps {
list-style: none;
counter-reset: st;
margin: 0.8rem 0;
}
.steps li {
counter-increment: st;
position: relative;
padding: 0.45rem 0 0.45rem 2.2rem;
font-size: 0.92rem;
}
.steps li::before {
content: counter(st);
position: absolute;
left: 0;
top: 0.5rem;
width: 1.5em;
height: 1.5em;
border-radius: 50%;
font-family: var(--mono);
font-size: 0.72rem;
text-align: center;
line-height: 1.5em;
font-weight: 700;
}
.half.client .steps li { color: var(--client-soft); border-bottom: 1px dotted var(--client-line); }
.half.client .steps li::before { background: var(--client-accent); color: #16120c; }
.half.server .steps li { color: var(--server-soft); border-bottom: 1px dotted var(--server-line); }
.half.server .steps li::before { background: var(--server-accent); color: #fff; }
/* hero band */
.band.hero .half { padding-top: 3.4rem; padding-bottom: 3rem; }
.band.hero h1 {
font-size: clamp(1.9rem, 4.5vw, 2.9rem);
line-height: 1.05;
letter-spacing: -0.01em;
}
.band.hero .sub { font-size: 0.98rem; margin-top: 0.8rem; max-width: 26rem; }
/* code panel */
.codebox {
font-family: var(--mono);
font-size: 0.74rem;
line-height: 1.6;
border-radius: 8px;
padding: 0.9rem 1rem;
margin: 0.9rem 0;
overflow-x: auto;
white-space: pre;
}
.half.client .codebox { background: #0d0b07; border: 1px solid var(--client-line); color: #d8c9a8; }
.half.server .codebox { background: #fff; border: 1px solid var(--server-line); color: #1f4b44; }
.codebox .hl { font-weight: 700; }
.half.client .codebox .hl { color: var(--client-accent); }
.half.server .codebox .hl { color: var(--server-accent); }
.codebox .dim { opacity: 0.45; }
/* ── simulator ──────────────────────────── */
.simbar {
grid-column: 1 / -1;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 1.1rem;
background: linear-gradient(90deg, var(--client-bg2) 50%, var(--server-bg2) 50%);
}
.simbar button {
font-family: var(--mono);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
border: none;
border-radius: 6px;
padding: 0.6em 1.4em;
cursor: pointer;
}
#sim-start { background: linear-gradient(90deg, var(--client-accent), var(--server-accent)); color: #fff; }
#sim-start:disabled { opacity: 0.5; cursor: default; }
#sim-reset { background: transparent; color: #888; border: 1px solid #666; }
.sim-clock {
font-family: var(--mono);
font-size: 0.85rem;
font-weight: 700;
color: #aaa;
min-width: 5.5em;
text-align: center;
}
/* fake browser windows */
.browser {
border-radius: 10px;
overflow: hidden;
margin: 0.4rem 0 1rem;
font-size: 0.8rem;
}
.half.client .browser { border: 1px solid var(--client-line); background: #0d0b07; }
.half.server .browser { border: 1px solid var(--server-line); background: #fff; }
.browser .chrome {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
font-family: var(--mono);
font-size: 0.62rem;
}
.half.client .browser .chrome { background: #1a150d; color: var(--client-soft); border-bottom: 1px solid var(--client-line); }
.half.server .browser .chrome { background: var(--server-bg2); color: var(--server-soft); border-bottom: 1px solid var(--server-line); }
.browser .chrome .b-dots { display: flex; gap: 0.3rem; }
.browser .chrome .b-dots i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: 0.4; display: block; }
.browser .chrome .url {
flex: 1;
border-radius: 4px;
padding: 0.15em 0.6em;
}
.half.client .browser .chrome .url { background: #0d0b07; }
.half.server .browser .chrome .url { background: #fff; }
.viewport {
min-height: 150px;
padding: 0.8rem;
position: relative;
display: flex;
flex-direction: column;
gap: 0.45rem;
justify-content: center;
align-items: center;
}
/* skeleton pieces revealed by the sim */
.viewport .piece {
width: 100%;
border-radius: 4px;
opacity: 0;
transition: opacity 0.25s;
}
.viewport .piece.show { opacity: 1; }
.viewport .bar { height: 14px; }
.viewport .bar.w60 { width: 60%; align-self: flex-start; }
.viewport .bar.w85 { width: 85%; align-self: flex-start; }
.viewport .box { height: 52px; }
.half.client .viewport .piece { background: var(--client-line); }
.half.client .viewport .piece.hero-piece { background: var(--client-accent); height: 18px; }
.half.server .viewport .piece { background: var(--server-line); }
.half.server .viewport .piece.hero-piece { background: var(--server-accent); height: 18px; }
.viewport .msg {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-family: var(--mono);
font-size: 0.68rem;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.viewport .msg.show { opacity: 1; }
.half.client .viewport .msg { color: var(--client-soft); }
.half.server .viewport .msg { color: var(--server-soft); }
.spinner {
width: 22px;
height: 22px;
border: 3px solid var(--client-line);
border-top-color: var(--client-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.jsbar {
width: 70%;
height: 6px;
border-radius: 3px;
background: var(--client-line);
overflow: hidden;
}
.jsbar i {
display: block;
height: 100%;
width: 0;
background: var(--client-accent);
transition: width 0.3s linear;
}
.hydrate-flash { animation: flash 0.5s; }
@keyframes flash {
0% { box-shadow: inset 0 0 0 3px var(--server-accent); }
100% { box-shadow: inset 0 0 0 0 transparent; }
}
/* metric lamps */
.metrics {
display: flex;
gap: 0.5rem;
font-family: var(--mono);
font-size: 0.62rem;
flex-wrap: wrap;
}
.lamp {
border-radius: 4px;
padding: 0.3em 0.7em;
letter-spacing: 0.08em;
opacity: 0.4;
transition: opacity 0.2s;
}
.lamp .ms { font-weight: 700; }
.half.client .lamp { border: 1px solid var(--client-line); color: var(--client-soft); }
.half.server .lamp { border: 1px solid var(--server-line); color: var(--server-soft); }
.lamp.lit { opacity: 1; }
.half.client .lamp.lit { border-color: var(--client-accent); color: var(--client-accent); }
.half.server .lamp.lit { border-color: var(--server-accent); color: var(--server-accent); }
/* mirrored spec table */
.spec {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-top: 0.6rem;
}
.spec td {
padding: 0.5rem 0.2rem;
vertical-align: top;
}
.spec td:first-child {
font-family: var(--mono);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.1em;
white-space: nowrap;
padding-right: 1rem;
width: 7em;
}
.half.client .spec td { border-bottom: 1px dotted var(--client-line); color: var(--client-soft); }
.half.client .spec td:first-child { color: var(--client-accent); }
.half.server .spec td { border-bottom: 1px dotted var(--server-line); color: var(--server-soft); }
.half.server .spec td:first-child { color: var(--server-accent); }
/* verdict chips */
.chip {
display: inline-block;
font-family: var(--mono);
font-size: 0.66rem;
border-radius: 20px;
padding: 0.2em 0.8em;
margin: 0.15rem 0.2rem 0.15rem 0;
}
.half.client .chip { border: 1px solid var(--client-accent); color: var(--client-accent); }
.half.server .chip { border: 1px solid var(--server-accent); color: var(--server-accent); }
/* footer band */
.band.footer .half {
padding-top: 1.2rem;
padding-bottom: 1.4rem;
font-family: var(--mono);
font-size: 0.66rem;
}
.band.footer .half.client { color: var(--client-soft); }
.band.footer .half.server { color: var(--server-soft); }
@media (max-width: 860px) {
.band { grid-template-columns: 1fr; }
.band::after { display: none; }
.half { padding: 1.8rem 1.2rem; }
.topbar .dots { display: none; }
}// Load-race simulator: runs the CSR and SSR waterfalls concurrently on a
// shared clock, lighting metric lamps as each milestone is reached.
// Nav dots scroll manually (fragment navigation is blocked inside sandboxed
// srcdoc iframes) and a scrollspy tracks the current band.
const $ = (id) => document.getElementById(id);
const startBtn = $("sim-start");
const resetBtn = $("sim-reset");
const clock = $("sim-clock");
let timers = [];
let clockTimer = null;
function lamp(id, ms) {
const el = $(id);
el.classList.add("lit");
el.querySelector(".ms").textContent = `${ms} ms`;
}
function msg(side, text) {
$(`${side}-msg-txt`).textContent = text;
$(`${side}-msg`).classList.add("show");
}
function clearMsg(side) {
$(`${side}-msg`).classList.remove("show");
}
function showPieces(side) {
$(`${side}-vp`).querySelectorAll(".piece").forEach((p, i) => {
timers.push(setTimeout(() => p.classList.add("show"), i * 90));
});
}
function at(ms, fn) {
timers.push(setTimeout(fn, ms));
}
const DURATION = 2600; // total simulated window (1 real ms = 1 "network" ms)
function run() {
reset();
startBtn.disabled = true;
const t0 = performance.now();
clockTimer = setInterval(() => {
const t = Math.min(DURATION, Math.round(performance.now() - t0));
clock.textContent = `${t} ms`;
if (t >= DURATION) clearInterval(clockTimer);
}, 50);
// ── CSR lane ──────────────────────────────
msg("csr", "GET / …");
at(100, () => {
lamp("csr-ttfb", 100);
msg("csr", "200 OK — empty shell + <script src=bundle.js>");
});
at(400, () => {
msg("csr", "downloading bundle.js (280 KB)…");
$("csr-jsbar-wrap").hidden = false;
});
for (let p = 1; p <= 10; p++) {
at(400 + p * 140, () => {
$("csr-jsbar").style.width = `${p * 10}%`;
});
}
at(1850, () => {
$("csr-jsbar-wrap").hidden = true;
$("csr-spinner").hidden = false;
msg("csr", "app booted — fetching /api/products…");
});
at(2550, () => {
$("csr-spinner").hidden = true;
clearMsg("csr");
showPieces("csr");
lamp("csr-fcp", 2600);
lamp("csr-tti", 2600);
});
// ── SSR lane ──────────────────────────────
msg("ssr", "GET / … server fetching data + rendering…");
at(400, () => {
lamp("ssr-ttfb", 400);
msg("ssr", "200 OK — full HTML streaming in");
});
at(520, () => {
clearMsg("ssr");
showPieces("ssr");
lamp("ssr-fcp", 550);
});
at(900, () => msg("ssr", "downloading bundle.js in background…"));
at(1350, () => {
clearMsg("ssr");
const vp = $("ssr-vp");
vp.classList.remove("hydrate-flash");
void vp.offsetWidth; // restart the flash animation
vp.classList.add("hydrate-flash");
lamp("ssr-tti", 1400);
});
at(DURATION, () => {
startBtn.disabled = false;
startBtn.textContent = "▶ RUN IT AGAIN";
});
}
function reset() {
timers.forEach(clearTimeout);
timers = [];
if (clockTimer) clearInterval(clockTimer);
clock.textContent = "0 ms";
startBtn.disabled = false;
["csr", "ssr"].forEach((side) => {
$(`${side}-vp`).querySelectorAll(".piece").forEach((p) => p.classList.remove("show"));
$(`${side}-vp`).classList.remove("hydrate-flash");
msg(side, "idle — press simulate");
});
$("csr-jsbar-wrap").hidden = true;
$("csr-jsbar").style.width = "0";
$("csr-spinner").hidden = true;
document.querySelectorAll(".lamp").forEach((l) => {
l.classList.remove("lit");
l.querySelector(".ms").textContent = "—";
});
}
startBtn.addEventListener("click", run);
resetBtn.addEventListener("click", () => {
reset();
startBtn.textContent = "▶ SIMULATE PAGE LOAD";
});
reset();
// ── nav dots + scrollspy ────────────────────
const dotLinks = Array.from(document.querySelectorAll('.topbar .dots a[href^="#"]'));
dotLinks.forEach((link) => {
link.addEventListener("click", (e) => {
const target = document.querySelector(link.getAttribute("href"));
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
const bands = dotLinks.map((a) => document.querySelector(a.getAttribute("href"))).filter(Boolean);
const spy = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const idx = bands.indexOf(entry.target);
dotLinks.forEach((a, i) => a.classList.toggle("current", i === idx));
});
},
{ rootMargin: "-35% 0px -55% 0px" }
);
bands.forEach((b) => spy.observe(b));<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CSR vs SSR — Split-Screen Explainer</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="topbar">
<span class="brand">CLIENT <span class="vs">vs</span> SERVER</span>
<div class="dots">
<a href="#b1" title="Intro"></a>
<a href="#b2" title="What rendering means"></a>
<a href="#b3" title="The flow"></a>
<a href="#b4" title="Hydration"></a>
<a href="#b5" title="Race"></a>
<a href="#b6" title="Metrics"></a>
<a href="#b7" title="The spectrum"></a>
</div>
</div>
<!-- ═══ Band 1 · Hero ═══ -->
<section class="band hero" id="b1">
<div class="half client">
<span class="tag">Client-side rendering</span>
<h1>The browser builds the page.</h1>
<p class="sub">The server ships an almost-empty HTML shell plus a JavaScript bundle. The app boots <strong>in the browser</strong>, fetches its data, and renders everything there.</p>
</div>
<div class="half server">
<span class="tag">Server-side rendering</span>
<h1>The server builds the page.</h1>
<p class="sub">The server runs the app <strong>per request</strong>, fetches the data, and responds with finished HTML. The browser paints it immediately, then JavaScript takes over.</p>
</div>
</section>
<!-- ═══ Band 2 · What rendering means ═══ -->
<section class="band" id="b2">
<div class="half client">
<h3>What "rendering" means here</h3>
<p>Rendering = turning <strong>components + data into HTML</strong>. The question is never <em>whether</em> it happens — it's <strong>where</strong> and <strong>when</strong>.</p>
<p>In CSR that transformation runs on your user's device, on every visit, after the JavaScript arrives and executes.</p>
<div class="codebox"><span class="dim"><!-- what the server sends --></span>
<body>
<span class="hl"><div id="root"></div></span> <span class="dim">← empty</span>
<script src="bundle.js"></script>
</body></div>
</div>
<div class="half server">
<h3>What "rendering" means here</h3>
<p>Same transformation, different machine: the server (or an edge node) runs it <strong>before responding</strong>, so HTML arrives already full of content.</p>
<p>Your user's device only has to paint — the heavy lifting happened on hardware you control.</p>
<div class="codebox"><span class="dim"><!-- what the server sends --></span>
<body>
<div id="root">
<span class="hl"><h1>Best Trail Shoes 2026</h1>
<article>…full content…</article></span>
</div>
<script src="bundle.js"></script>
</body></div>
</div>
</section>
<!-- ═══ Band 3 · The flow ═══ -->
<section class="band" id="b3">
<div class="half client">
<h3>The request, step by step</h3>
<ol class="steps">
<li><strong>HTML arrives fast</strong> — it's tiny and static. But the screen is blank: there's nothing in it.</li>
<li><strong>Download the bundle</strong> — often hundreds of KB of JavaScript. On a slow phone this is the long pole.</li>
<li><strong>Parse & execute</strong> — the framework boots, the router resolves, components mount.</li>
<li><strong>Fetch the data</strong> — <em>now</em> the API calls start. A second round-trip the server flow doesn't need.</li>
<li><strong>Render</strong> — content finally appears, already interactive.</li>
</ol>
<p>First paint is late, but once loaded, navigation between pages is instant — the app is already in memory.</p>
</div>
<div class="half server">
<h3>The request, step by step</h3>
<ol class="steps">
<li><strong>Server receives the request</strong> — runs the app for this URL.</li>
<li><strong>Fetch the data on the server</strong> — DB and APIs are milliseconds away, not a mobile round-trip.</li>
<li><strong>HTML arrives full</strong> — slightly later than CSR's shell (the server did real work), but it's the <em>finished page</em>.</li>
<li><strong>Content paints immediately</strong> — the user reads while JavaScript still downloads.</li>
<li><strong>Hydrate</strong> — the bundle attaches interactivity to the HTML that's already on screen.</li>
</ol>
<p>First paint is early, but each full navigation costs a server round-trip — unless a client router takes over after load.</p>
</div>
</section>
<!-- ═══ Band 4 · Hydration ═══ -->
<section class="band" id="b4">
<div class="half client">
<h3>No hydration needed</h3>
<p>CSR skips this problem entirely: the DOM is <strong>born from JavaScript</strong>, so it's interactive the moment it exists.</p>
<p>The cost was paid earlier — the user stared at a spinner while the app booted. There is no "visible but dead" phase; there's a "nothing visible at all" phase instead.</p>
<span class="chip">blank → interactive</span>
</div>
<div class="half server">
<h3>Hydration ⚡</h3>
<p>Server HTML is a <strong>picture of the app</strong> — visible but inert. Hydration is the step where the downloaded bundle re-runs the components, matches them to the existing markup, and attaches the event listeners.</p>
<p>Between first paint and hydration there's an <strong>uncanny valley</strong>: the page looks ready but buttons don't respond yet. Keeping bundles small shrinks that gap; streaming and islands (band 7) attack it structurally.</p>
<span class="chip">visible → then interactive</span>
</div>
</section>
<!-- ═══ Band 5 · The race ═══ -->
<section class="band" id="b5">
<div class="simbar">
<button id="sim-start">▶ SIMULATE PAGE LOAD</button>
<span class="sim-clock" id="sim-clock">0 ms</span>
<button id="sim-reset">RESET</button>
</div>
<div class="half client">
<h3>Same app, slow 4G — client side</h3>
<div class="browser">
<div class="chrome"><span class="b-dots"><i></i><i></i><i></i></span><span class="url">https://shop.example — CSR build</span></div>
<div class="viewport" id="csr-vp">
<div class="msg" id="csr-msg"><span id="csr-msg-txt">idle — press simulate</span><span class="jsbar" id="csr-jsbar-wrap" hidden><i id="csr-jsbar"></i></span><span class="spinner" id="csr-spinner" hidden></span></div>
<div class="piece hero-piece"></div>
<div class="piece bar w60"></div>
<div class="piece box"></div>
<div class="piece bar w85"></div>
</div>
</div>
<div class="metrics">
<span class="lamp" id="csr-ttfb">TTFB <span class="ms">—</span></span>
<span class="lamp" id="csr-fcp">FCP <span class="ms">—</span></span>
<span class="lamp" id="csr-tti">TTI <span class="ms">—</span></span>
</div>
</div>
<div class="half server">
<h3>Same app, slow 4G — server side</h3>
<div class="browser">
<div class="chrome"><span class="b-dots"><i></i><i></i><i></i></span><span class="url">https://shop.example — SSR build</span></div>
<div class="viewport" id="ssr-vp">
<div class="msg" id="ssr-msg"><span id="ssr-msg-txt">idle — press simulate</span></div>
<div class="piece hero-piece"></div>
<div class="piece bar w60"></div>
<div class="piece box"></div>
<div class="piece bar w85"></div>
</div>
</div>
<div class="metrics">
<span class="lamp" id="ssr-ttfb">TTFB <span class="ms">—</span></span>
<span class="lamp" id="ssr-fcp">FCP <span class="ms">—</span></span>
<span class="lamp" id="ssr-tti">TTI <span class="ms">—</span></span>
</div>
</div>
</section>
<!-- ═══ Band 6 · Metrics compared ═══ -->
<section class="band" id="b6">
<div class="half client">
<h3>Scorecard</h3>
<table class="spec">
<tr><td>TTFB</td><td><strong>Excellent</strong> — the shell is static, cache it on a CDN worldwide.</td></tr>
<tr><td>FCP</td><td><strong>Slow</strong> — nothing paints until JS downloads, parses, runs, and fetches.</td></tr>
<tr><td>TTI</td><td>Same moment as FCP — late, but no dead-looking UI.</td></tr>
<tr><td>SEO</td><td>Riskier — crawlers must execute JS to see content; social previews often see the empty shell.</td></tr>
<tr><td>Server cost</td><td><strong>Minimal</strong> — static file hosting; users' devices do the rendering.</td></tr>
<tr><td>Best at</td><td>Apps behind login: dashboards, editors, admin panels — long sessions, SEO irrelevant.</td></tr>
</table>
</div>
<div class="half server">
<h3>Scorecard</h3>
<table class="spec">
<tr><td>TTFB</td><td><strong>Slower</strong> — the server works before the first byte (data fetch + render).</td></tr>
<tr><td>FCP</td><td><strong>Fast</strong> — real content in the first response; paints as soon as it arrives.</td></tr>
<tr><td>TTI</td><td>After hydration — the visible-but-inert gap is the tax.</td></tr>
<tr><td>SEO</td><td><strong>Solid</strong> — crawlers and link previews get finished HTML, no JS required.</td></tr>
<tr><td>Server cost</td><td>Real — CPU per request; caching and edge rendering keep it sane.</td></tr>
<tr><td>Best at</td><td>Content the world must see fast: stores, landing pages, articles, anything shared or indexed.</td></tr>
</table>
</div>
</section>
<!-- ═══ Band 7 · The spectrum ═══ -->
<section class="band" id="b7">
<div class="half client">
<h3>Beyond the binary — from this side</h3>
<p>Modern frameworks blur the line by moving work <em>off</em> the client:</p>
<p><span class="chip">SSG</span> Pre-render at <strong>build time</strong> — pure HTML on a CDN, zero server work per request. Perfect for content that changes on deploy.</p>
<p><span class="chip">ISR</span> Static pages that <strong>re-generate in the background</strong> on a timer or on demand — static speed, fresh-ish data.</p>
<p>Rule of thumb: the less your page changes per user, the further toward build-time it should render.</p>
</div>
<div class="half server">
<h3>Beyond the binary — from this side</h3>
<p>And by shrinking what the client must hydrate:</p>
<p><span class="chip">Streaming SSR</span> The server <strong>flushes HTML in chunks</strong> — the shell paints while slow data is still loading, with placeholders that fill in.</p>
<p><span class="chip">Islands / RSC</span> Most of the page ships as <strong>zero-JS HTML</strong>; only the interactive widgets (cart button, search box) hydrate. The uncanny valley shrinks to a few small islands.</p>
<p>Rule of thumb: server-first for content, client islands for interaction — choose per <em>page</em>, not per <em>app</em>.</p>
</div>
</section>
<section class="band footer">
<div class="half client">CSR — render where the user is.</div>
<div class="half server">SSR — render where the data is. · Illustrative timings, simulated network.</div>
</section>
<script src="script.js"></script>
</body>
</html>CSR vs SSR (split-screen explainer with load race)
An educational page whose layout is its argument: the viewport is split down the middle by a dashed seam, with CLIENT (CSR) living on a warm near-black half (amber accent) and SERVER (SSR) on a cool light half (teal accent). Even the sticky top bar is painted half-and-half, carrying a CLIENT vs SERVER wordmark and a row of dot anchors with scrollspy.
Content flows as seven mirrored bands — each explains the same concept from both sides in parallel: what “rendering” actually means (with the empty-shell vs full-HTML response side by side in code panels), the request waterfall as numbered step cards, hydration vs no-hydration, per-side scorecards (TTFB, FCP, TTI, SEO, server cost, best-fit workloads) as dotted spec tables, and the modern spectrum (SSG and ISR on the client half; streaming SSR and islands/RSC on the server half).
The centerpiece band is the load race: a gradient Simulate Page Load button and a shared millisecond clock drive two fake browser windows concurrently. The CSR window receives its empty shell fast, then grinds through a JS-bundle progress bar and a data-fetch spinner before content pops in late; the SSR window paints its full skeleton almost immediately, then flashes a hydration ring when interactivity attaches. Metric lamps (TTFB / FCP / TTI) light with their timings on each side as reached, and a Reset button re-arms everything for another run.
Vanilla JS throughout: a setTimeout schedule on a shared performance.now() clock for the race, IntersectionObserver for the scrollspy, and scrollIntoView interception for the dot anchors (fragment navigation is blocked in sandboxed preview iframes). Below 860px the halves stack — CLIENT above SERVER — and the seam and dots hide.
Illustrative UI only — simulated network timings on a fictional shop.