Why this subject matters now
The frontend a practitioner is expected to understand in 2025 is not the frontend of 2019. Five years ago the default mental model was the single-page application: ship a bundle of JavaScript, let it fetch JSON and render everything in the browser, and treat the server as a dumb API. That model made the initial HTML nearly empty and pushed all the work onto the client's main thread and the client's network. It scaled badly in exactly the place it mattered, the first load on a mid-range phone on a congested cell link, and the industry spent the intervening years moving work back toward the server without giving up the interactivity that made SPAs attractive in the first place.
The concrete shifts a practitioner should be able to reason about:
React Server Components moved from an RFC to the default in the
Next.js App Router and Remix's successor, changing where components
execute and what ships to the browser; streaming SSR with Suspense
let a server flush HTML in pieces instead of blocking on the slowest
query; the measurement regime hardened around Core Web Vitals, and in
March 2024 Interaction to Next Paint (INP) replaced First Input Delay
(FID) as the responsiveness metric in the Core Web Vitals set, which
changed what "fast" means from "responds to the first tap" to "stays
responsive across the whole session"; TypeScript became the assumed
default rather than an option, and end-to-end type safety across the
client/server boundary via tools like zod and tRPC became a normal
expectation; and the deployment target moved toward the edge, with
caching semantics (stale-while-revalidate) that were once
an HTTP curiosity now sitting at the center of how pages stay both
fast and fresh. A senior frontend or full-stack engineer is expected
to hold all of this and to know, for any given page, where each byte
of work should run and why.
One meta-point worth stating because it frames the whole discussion: the site you are reading is a hand-written static site, plain HTML and a single stylesheet, no framework and no client-side router. That is a deliberate architectural choice, not a limitation. For content that is read far more often than it changes and has almost no interactive state, the correct rendering model is the one with the least machinery: static files served from a CDN, hydration cost zero, because there is nothing to hydrate. The rest of this page is about what to reach for when a page is not that, and the discipline is always to justify the complexity against what the content actually needs.
The critical rendering path
Everything downstream depends on understanding what the browser does between receiving the first byte of HTML and putting a pixel on the screen. The sequence is fixed and worth memorizing because the entire performance vocabulary is defined in terms of its stages. The authoritative descriptions live in the WHATWG HTML and DOM standards and the CSS specifications; MDN and web.dev document the observable behavior. The pipeline:
bytes ──► HTML parse ──► DOM tree
│
bytes ──► CSS parse ──► CSSOM tree
│
DOM + CSSOM ─► render tree (visible nodes + computed style)
│
LAYOUT (reflow): geometry, box positions & sizes
│
PAINT: fill pixels into layers (raster)
│
COMPOSITE: assemble layers on the GPU ──► screen
Parse to DOM. The HTML parser is a streaming state
machine defined byte-for-byte in the WHATWG HTML standard. It reads
the response as it arrives and incrementally constructs the Document
Object Model, a tree of nodes. Crucially, the parser is not free to
run to completion whenever it likes: a synchronous
<script> without async or
defer blocks the parser, because the script might call
document.write and change the token stream. This is why
script placement and the defer/async
attributes matter: defer executes scripts after parsing
in document order, async executes them as soon as they
arrive in no guaranteed order.
Parse to CSSOM. CSS is render-blocking by default.
The browser will not paint until it has built the CSS Object Model,
because painting a node requires its fully computed style, and a rule
appearing late in the stylesheet can override an earlier one. A
stylesheet in the <head> therefore gates the first
paint. This is the mechanism behind the "inline critical CSS"
technique: put the styles needed for above-the-fold content directly
in the HTML so the first paint does not wait on a separate CSS
round-trip.
Render tree. The browser walks the DOM, and for each
node computes its style from the CSSOM and decides whether it is
visible. Nodes with display: none are absent from the
render tree entirely; nodes with visibility: hidden are
present but not painted, because they still occupy layout space. The
render tree contains only what will be shown, annotated with computed
style.
Layout (reflow). Given the render tree, the browser computes the geometry of every box: its position, width, height, and how it flows relative to its siblings and parent. This is the layout or reflow step, and it is expensive because it is fundamentally a global constraint solve: changing the width of one element can shift the position of everything after it. Layout is where the box model, flexbox, and grid algorithms run.
Paint. With geometry known, the browser fills in pixels: text, colors, borders, shadows, images. Paint produces raster data, and the browser may split content into multiple layers so that an element that changes frequently can be repainted without touching the rest.
Composite. The painted layers are handed to the GPU and assembled into the final image. Compositing is cheap relative to layout and paint because it is just positioning and blending already-rastered bitmaps. The significance of this last stage is the single most useful performance fact in frontend work, and it gets its own subsection below.
What forces layout, and layout thrashing
Layout is computed lazily. The browser batches DOM mutations and
recomputes geometry once, ideally right before the next frame is
painted. This batching is defeated the moment a script reads a
property whose value depends on up-to-date layout. Reading
offsetHeight, getBoundingClientRect(),
scrollTop, clientWidth, or the computed
style of an element forces the browser to flush any pending style and
layout changes synchronously so it can return a correct value. This is
a forced synchronous layout, and by itself it is fine.
It becomes layout thrashing when a loop interleaves writes and reads. Each write invalidates layout; each subsequent read forces a recompute. A loop over \(n\) elements that writes a style and then reads a geometry on each iteration performs \(O(n)\) forced layouts instead of one, turning a batched operation into a quadratic-feeling stall. The fix is to separate the phases: read all the geometry first, then perform all the writes, so the browser flushes layout at most once. This read-then-write discipline is the core of libraries like FastDOM and is worth internalizing directly rather than reaching for a library.
// Layout thrashing: read-write-read-write forces a reflow every iteration.
function badResize(boxes: HTMLElement[]) {
for (const box of boxes) {
// WRITE: invalidates layout
box.style.width = "100px";
// READ: forces a synchronous layout flush to return a correct value
const h = box.offsetHeight; // O(n) forced layouts total
box.style.height = h + "px"; // WRITE again
}
}
// Fixed: batch all reads, then all writes. One layout flush.
function goodResize(boxes: HTMLElement[]) {
const heights = boxes.map((b) => b.offsetHeight); // READ phase
boxes.forEach((b, i) => { // WRITE phase
b.style.width = "100px";
b.style.height = heights[i] + "px";
});
}
The compositor thread: why transform and opacity are cheap
A browser runs the JavaScript main thread and a separate compositor thread. The main thread does parsing, style, layout, paint, and runs your JavaScript. The compositor thread takes already-painted layers and positions them. Because these are different threads, work that can be done entirely on the compositor does not compete with JavaScript for the main thread and can proceed even while the main thread is busy.
Two CSS properties can be handled entirely by the compositor when an
element is promoted to its own layer: transform and
opacity. Translating, scaling, rotating, or fading a
layer changes only how an existing bitmap is placed and blended; it
requires neither layout nor repaint. Animating transform:
translateX() from 0 to 300px is a compositor-only operation
that can hold 60 frames per second even under main-thread load.
Animating left from 0 to 300px, by contrast, changes box
geometry, so every frame triggers layout and paint on the main thread.
The visual result is identical; the cost is not. This is the concrete
reason the standard advice is to animate transform and
opacity and nothing else, and to hint the browser with
will-change: transform when an animation is imminent so
the layer is promoted in advance.
| Property animated | Triggers | Thread | Cost per frame |
|---|---|---|---|
width, height, top, left, margin | layout + paint + composite | main | high |
color, background, box-shadow | paint + composite | main | medium |
transform, opacity | composite only | compositor | low |
The network side: getting the bytes there
The rendering path cannot start until bytes arrive, and the shape of the network is where a surprising fraction of perceived latency comes from. This section is deliberately brief because the transport layer has its own treatment; see computer networks for the derivation of TCP throughput, the QUIC handshake, and congestion control. Here the concern is only what the frontend can control.
HTTP/2 versus HTTP/3 and QUIC
HTTP/1.1 allowed one in-flight request per TCP connection (pipelining was specified but unusable in practice), so browsers opened six parallel connections per origin and requests queued behind each other, the classic head-of-line blocking at the application layer. HTTP/2 (RFC 7540) fixed this by multiplexing many logical streams over a single TCP connection with independent framing, so a slow response no longer blocks the ones behind it at the HTTP layer. But HTTP/2 still runs over TCP, and TCP delivers bytes strictly in order: a single lost packet stalls every multiplexed stream until it is retransmitted, because TCP will not hand later bytes to the application before the gap is filled. This is transport-layer head-of-line blocking, and HTTP/2 could not solve it.
HTTP/3 (RFC 9114) solves it by abandoning TCP for QUIC (RFC 9000), a transport built on UDP that implements its own streams with per-stream loss recovery. A lost packet in one QUIC stream does not block delivery on the others. QUIC also folds the transport and TLS handshakes together, so a connection is established in a single round-trip (and zero round-trips on resumption), where TCP plus TLS 1.3 needs at least two. On a lossy mobile link the difference is large and directly visible in time-to-first-byte. The practical takeaway for the frontend: prefer a single origin so connection setup is amortized, and understand that on modern infrastructure the transport is likely HTTP/3 to the CDN edge and something simpler behind it.
The waterfall and resource hints
The single most useful diagnostic artifact in frontend performance is
the network waterfall: a timeline showing when each resource was
discovered, queued, and downloaded. A request cannot start until the
browser discovers it, and discovery is often gated behind another
resource. The canonical pathology is a chain: HTML references a CSS
file, the CSS references a font via @font-face, so the
font cannot even begin downloading until the CSS has been fetched and
parsed. Each link in the chain costs a round-trip. Flattening these
chains is most of what performance tuning is.
Resource hints let the developer break the dependency by telling the browser about a resource before it would naturally discover it. The standardized hints, documented in the W3C Resource Hints and Preload specs and on MDN:
<link rel="preload">tells the browser to fetch a resource needed for the current navigation at high priority, before it is discovered in the parse. Used for the LCP image, a critical font, or a late-discovered script.<link rel="preconnect">performs the DNS, TCP, and TLS handshake to a third-party origin ahead of time, so the first request to it does not pay connection setup.<link rel="prefetch">fetches a resource for a future navigation at low priority, filling the cache speculatively.<link rel="dns-prefetch">resolves DNS only, the cheapest and most conservative hint.
The trap with hints is that they are a zero-sum allocation of bandwidth and connection slots. Preloading everything preloads nothing, because the browser's own priority heuristics get overridden and genuinely critical resources lose their slot. A hint is a claim that a specific resource is more important than the browser's default guess, and it should be used only when that claim is true and measurable.
Rendering models, derived against each other
The central design decision for any page is where and when the HTML is produced. There are five points on the spectrum, and each is the right answer for a different combination of two variables: how often the content changes, and how much per-request personalization it needs. Naming the axes first makes the whole taxonomy fall out.
per-request data / personalization
low ───────────────────────────► high
changes high │ ISR / streaming SSR SSR (per request)
often │
│ │
▼ low │ SSG (build once) SSR w/ caching
rarely │
CSR: client-side rendering (the SPA)
The server sends a near-empty HTML shell and a JavaScript bundle. The browser downloads and parses the bundle, runs it, and only then does the framework build the DOM and fetch data. Time to meaningful content is bounded below by: download the HTML, discover and download the JS, parse and execute the JS, then start the data fetch, then render. Nothing is visible until the JavaScript has run. The advantage is that after that first cost, navigation between routes is instant and entirely client-side, and the server is a simple static host plus an API. The disadvantage is that the first load is the worst case for the slowest device, and search engines and link-preview crawlers see an empty page unless they execute JavaScript. CSR is correct for application-like surfaces behind a login where the first paint speed matters less than in-app navigation: dashboards, editors, internal tools.
SSR: server-side rendering
The server runs the component tree for each request, produces complete HTML, and sends it. The browser can paint meaningful content as soon as the HTML arrives, before any JavaScript runs, which is a large win for LCP and for crawlers. Then the same JavaScript is sent to the client to hydrate the static HTML into an interactive app (the hydration cost is derived below). SSR is correct when content is personalized or changes per request: a logged-in feed, a price-sensitive product page, anything where the HTML genuinely depends on who is asking. Its cost is server compute per request and a slower time-to-first-byte, because the server must run the render and often wait on data before it can send anything.
SSG: static site generation
The HTML is produced once at build time and served as static files. Time-to-first-byte is the cost of a static file from a CDN edge, essentially a single round-trip, and there is no per-request server compute. This is optimal for content that is the same for everyone and changes rarely: documentation, marketing pages, blogs, this site. The limitation is the build: if there are a million pages or the content changes every minute, rebuilding the whole site is impractical, which is exactly the gap ISR fills.
ISR: incremental static regeneration
ISR serves a statically generated page but attaches a revalidation
interval. The first request after the interval expires still gets the
stale static page instantly, and in the background the server
regenerates the page and replaces the cached copy for the next
visitor. This is the stale-while-revalidate pattern
(derived in the caching section) applied to page generation. It gives
SSG's first-byte speed with SSR's freshness for content that changes
on a timescale of minutes to hours rather than per request, an
e-commerce catalog, a news homepage, and it decouples the number of
pages from the build time because pages are generated on demand and
then cached.
Streaming SSR
Plain SSR must wait for all data before it can send any HTML, because the whole tree renders at once. Streaming SSR, enabled by Suspense on the server, lets the server send the shell and the fast parts immediately, then flush the slow parts as their data resolves, over the same response. The browser paints the shell and a loading placeholder within the first round-trip, and the slow section streams in when ready. This decouples time-to-first-byte from the slowest query: a page with a fast header and a slow personalized recommendation panel no longer holds the header hostage to the panel. Streaming is the server-side complement to Suspense on the client and is the default rendering mode for RSC frameworks.
React Server Components and the serialization boundary
React Server Components (RSC), specified in the React RFC and shipped in the Next.js App Router, are a different axis from SSR. SSR is about when HTML is produced; RSC is about which components ever reach the client at all. A Server Component runs only on the server. Its code, and every library it imports, stays on the server and is never included in the client bundle. It can read the database or the filesystem directly because it runs in a trusted server environment. What it produces is not HTML and not a JavaScript closure; it is a serialized description of the rendered tree, an RSC payload, which the client's React runtime uses to update the DOM.
The boundary is explicit. A component is a Server Component by default
in the App Router; the "use client" directive at the top
of a module marks that module and its imports as Client Components,
which do ship to the browser and can use state, effects, and event
handlers. The rule that makes the model coherent: Server Components can
import and render Client Components, but not the reverse in the naive
sense, because a Client Component runs where the server code and its
secrets are not present. Data flows across the boundary only as
serializable props; you cannot pass a function or a database handle
from a Server Component to a Client Component, because those do not
survive serialization. This constraint is the entire discipline of
the model: interactivity lives in small client islands, and everything
static or data-bound stays on the server and ships nothing.
// app/posts/[slug]/page.tsx
// A Server Component. This code, and the db client it imports, never ship
// to the browser. It runs in a trusted server environment and can touch
// the database directly. It returns a serialized tree, not HTML strings.
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; // heavy dep: stays server-side
import { LikeButton } from "./like-button"; // a Client Component island
export default async function PostPage(
{ params }: { params: { slug: string } },
) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) notFound();
// renderMarkdown pulls in ~40 KB of parser; because this is a Server
// Component, none of that reaches the client bundle.
const html = renderMarkdown(post.body);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
{/* Only serializable props cross the boundary: a number, not a handle */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
);
}
// app/posts/[slug]/like-button.tsx
"use client"; // this module and its imports DO ship to the browser
import { useState, useTransition } from "react";
import { likePost } from "./actions"; // a Server Action
export function LikeButton(
{ postId, initialLikes }: { postId: string; initialLikes: number },
) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
return (
<button
disabled={isPending}
onClick={() => {
setLikes((n) => n + 1); // optimistic update
startTransition(async () => {
const next = await likePost(postId); // calls the server, no fetch()
setLikes(next); // reconcile with server truth
});
}}
>
{likes} likes
</button>
);
}
An article page has 20 components: 6 are interactive (comment box, like button, share menu, and so on), averaging 8 KB of client JavaScript each including their dependencies, and 14 are display only (rendered prose, author card, related-links list), averaging 6 KB each. The display components together also pull in a markdown renderer and a date-formatting library totaling 40 KB. The React runtime is 45 KB. Compute the client JavaScript shipped (a) as a conventional SSR app where every component hydrates, and (b) as an RSC app where the display-only components are Server Components. State the reduction, and estimate the main-thread parse-and-execute time saved assuming a rough 1 ms per KB on a mid-range phone.
Solution. Conventional SSR ships everything to the client for hydration:
\( 45\ (\text{runtime}) + 6\times 8\ (\text{interactive}) + 14\times 6\ (\text{display}) + 40\ (\text{libs}) \) \( = 45 + 48 + 84 + 40 = 217\ \text{KB}. \)
Under RSC, the 14 display-only components run on the server and ship no client code, and the markdown and date libraries they depend on stay on the server too. Only the runtime and the 6 interactive islands ship:
\( 45 + 6\times 8 = 45 + 48 = 93\ \text{KB}. \)
The reduction is \( (217 - 93)/217 = 124/217 = 57.1\% \). At a rough 1 ms per KB of parse-and-execute on a mid-range phone, the 124 KB removed is about 124 ms of main-thread time that no longer blocks interactivity on first load. The lesson is quantitative: RSC's benefit scales with how much of a page is static or data-bound rather than interactive, which for content-heavy pages is most of it. The 45 KB runtime is fixed and does not go away, so the model helps least for pages that are almost entirely interactive, dashboards and editors, which is consistent with CSR being the right model for those.
Hydration and its cost
Hydration is the process by which the client-side framework takes the server-rendered HTML and attaches interactivity: it walks the same component tree the server rendered, recreates the component instances and their state in memory, and wires up event listeners, all while reusing the existing DOM nodes rather than recreating them. The DOM is already there and already painted, but until hydration completes the page is a photograph: it looks ready, but a click does nothing, which produces the frustrating "looks loaded but is dead" interval.
The cost model is the reason islands and resumability exist. Hydration must, at minimum, download the component JavaScript, parse and execute it, and re-run the render of every component to reconstruct the tree in memory so it can be reconciled against the DOM. Let \(N\) be the number of components and let the client bundle be \(B\) bytes. A rough model of hydration time is
$$ T_{\text{hydrate}} \approx \underbrace{\frac{B}{r}}_{\text{download}} + \underbrace{c_p\, B}_{\text{parse}} + \underbrace{c_r\, N}_{\text{re-render}} $$
where \(r\) is throughput, \(c_p\) is the per-byte parse-and-compile cost, and \(c_r\) is the average per-component render cost. Two things fall out. First, the cost is proportional to how much ships and how many components exist, whether or not the user ever interacts with them: traditional hydration pays to make the entire page interactive even if the user only clicks one button. Second, the term \(c_p B\) grows with bundle size independent of network, so shipping less code, exactly what RSC does, reduces hydration cost directly.
Islands / partial hydration attack the \(N\) and \(B\) terms by hydrating only the interactive regions of a page and leaving the static regions as inert HTML forever. Astro's islands architecture and RSC's client-boundary model are two expressions of this idea: the static shell never hydrates because there is nothing to make interactive, so you pay hydration only for the islands. If a page is 90% static content and 10% interactive widgets, islands cut the hydration cost by roughly that ratio.
Resumability, the model behind Qwik, attacks the problem more aggressively by eliminating the re-render term entirely. Instead of re-running the components on the client to rebuild application state, the server serializes all the state and the event wiring into the HTML, and the client resumes from that serialized state without re-executing the component tree. Interactivity costs only the code for the specific handler that fires, downloaded lazily on the first interaction. The tradeoff is a larger serialized HTML payload and a more complex framework, in exchange for a hydration cost that is close to constant regardless of page size. Whether that trade pays depends on how interactive-heavy the page is; for content-dominant pages it is attractive, and it is one of the genuinely different points in the design space rather than a variation on React's approach.
A page ships a 300 KB client bundle over a link with 1.6 Mbps effective throughput (the standard emulated "Slow 4G" profile used by Lighthouse). Parse-and-compile costs roughly 1 ms/KB and the page has 250 components each costing about 0.05 ms to re-render during hydration. Estimate total hydration time with the model above. Then suppose an islands architecture leaves 80% of the components as inert static HTML and cuts the shipped bundle to 90 KB. Recompute, and state what fraction of the original hydration time remains.
Solution. Throughput \(r = 1.6\times 10^6 / 8 = 2.0\times 10^5\) bytes/s = 200 KB/s.
Full hydration:
download \( = 300\ \text{KB} / 200\ \text{KB/s} = 1.5\ \text{s} = 1500\ \text{ms} \);
parse \( = 1\ \text{ms/KB} \times 300\ \text{KB} = 300\ \text{ms} \);
re-render \( = 250 \times 0.05\ \text{ms} = 12.5\ \text{ms} \);
total \( \approx 1500 + 300 + 12.5 = 1812.5\ \text{ms} \).
Islands (90 KB, 50 components hydrate):
download \( = 90 / 200 = 0.45\ \text{s} = 450\ \text{ms} \);
parse \( = 90\ \text{ms} \); re-render \( = 50 \times 0.05 = 2.5\ \text{ms} \);
total \( \approx 450 + 90 + 2.5 = 542.5\ \text{ms} \).
The islands version is \( 542.5 / 1812.5 = 29.9\% \) of the original, a 3.3× reduction. Note where the saving comes from: the re-render term was never the bottleneck (12.5 ms out of 1812.5), so resumability's elimination of it would barely move this page. The download and parse of the bundle dominate, which is why the effective lever on hydration cost is almost always shipping less JavaScript, and why RSC and islands, which reduce \(B\), beat approaches that only reduce \(c_r N\) for typical pages.
The modern React model
React's programming model is declarative: a component is a function from props and state to a description of UI, and React's job is to make the actual DOM match that description efficiently. Understanding the runtime well enough to reason about performance requires knowing how that reconciliation happens and what the hooks actually do. The authoritative source is the React documentation at react.dev, which was rewritten around hooks and the concurrent model.
The reconciler and fiber, at the right altitude
When state changes, React re-runs the component function to produce a new tree of elements, then compares it against the previous tree and computes the minimal set of DOM mutations. This comparison is reconciliation. The data structure that makes it tractable is the fiber tree: each component instance corresponds to a fiber node holding its state, its position in the tree, and pointers to its child, sibling, and parent. The key property of the fiber architecture is that reconciliation is interruptible. React can process fibers in small units of work, pause to let the browser handle a high-priority event, and resume, rather than blocking the main thread until the whole tree is reconciled. This interruptibility is what makes concurrent features like transitions possible.
The practical consequence a developer must internalize is the role of
key in lists. Reconciliation matches old and new children
by position unless keys are provided; with stable keys it matches by
identity, so reordering a list moves DOM nodes rather than recreating
them and preserves their state. Using an array index as a key defeats
this precisely when it matters, during insertion or reordering, and is
the source of a large class of "the wrong input kept its value" bugs.
State and effects: useState, useEffect, and when memo helps
useState gives a component a piece of state that persists
across renders and, when updated, schedules a re-render.
useEffect runs a side effect after render and after the
DOM is committed, synchronizing the component with something outside
React, a subscription, a network request, a manual DOM manipulation,
and its dependency array controls when it re-runs. The most common
mistake is treating useEffect as a place to derive state
from other state; if a value can be computed during render from
existing props and state, it should be, not stored in a second state
and synchronized in an effect, which creates an extra render and a
class of tearing bugs.
useMemo and useCallback cache a computed
value or a function identity across renders so it is not recreated
unless its dependencies change. The critical and widely misunderstood
point is that memoization is not free: it costs memory to store the
cached value and a dependency comparison on every render, and it only
pays off when the cached computation is genuinely expensive or when the
stable identity prevents a costly re-render downstream (for example,
keeping a callback stable so a React.memo'd child does not
re-render). Wrapping every value in useMemo reflexively
adds overhead and complexity for no benefit and is a common
anti-pattern. React 19's compiler (React Compiler, formerly "React
Forget") automates much of this, memoizing where it can prove it is
safe, which reduces the need to reach for these hooks manually.
use(), Suspense, and transitions
Suspense is a mechanism for declaratively handling the loading state of
a subtree. A component can "suspend", signal that it is not ready
because it is waiting on data, and the nearest Suspense boundary above
it renders a fallback until it resolves. Combined with streaming SSR,
this is what lets the server flush a placeholder and then the real
content. The new use() API generalizes this: it unwraps a
promise (or context) during render, suspending the component until the
promise resolves, which lets data fetching be expressed inline in the
render rather than shuffled through useEffect and manual
loading flags.
Transitions address a different problem: keeping the interface
responsive while an expensive state update is in flight.
useTransition marks a state update as non-urgent, so React
can interrupt it to handle urgent updates like typing, and expose an
isPending flag to show a subtle loading state without
blocking input. The canonical use is a search box that filters a large
list: the keystroke updates urgently so the input stays responsive,
and the expensive filtered-list render happens in a transition that can
be interrupted by the next keystroke. This is the concurrent model
delivering on the promise the fiber architecture made.
State management: local versus server state
The most clarifying distinction in modern frontend state management is
between client state and server state, because they
have opposite characteristics and the industry spent years using the
wrong tool for the second. Client state is state the client owns: which
tab is open, the contents of a form, whether a menu is expanded. It is
synchronous, it is never stale, and the client is the source of truth.
Tools like useState, useReducer, and stores
like Zustand or Redux handle it well.
Server state is a cache of data the client does not own: the current user's posts, a product's price, a list of comments. It is asynchronous, it can become stale at any moment because someone else can change it, and the server is the source of truth. Treating it as client state, fetching it into a Redux store and manually keeping it in sync, produces exactly the bugs you would expect: stale data, duplicated fetches, and hand-rolled cache invalidation. The realization that server state deserves a purpose-built cache is why TanStack Query (React Query) and SWR exist, and it is one of the more important conceptual shifts in recent frontend practice.
The cache-and-revalidate model, and stale-while-revalidate
React Query and SWR implement a caching model with an explicit answer to the freshness question. Each query has a key and a fetcher. When a component requests data, the library returns whatever is in the cache immediately (possibly nothing on the first call), and separately decides whether to refetch in the background. SWR's name is literally the policy: stale-while-revalidate. Show the stale cached value now, and revalidate in the background, replacing it when the fresh value arrives. The user sees data immediately, and correctness catches up.
The freshness of this model can be reasoned about precisely. Let a resource change on the server at some rate, and let \(t_c\) be the time since the cache entry was last refreshed. The library defines a stale time \(s\): within \(t_c \le s\) the entry is considered fresh and is served with no network request at all; for \(t_c > s\) the entry is stale and, on the next access, is served immediately and triggers a background refetch. The user-perceived staleness of any served value is at most the interval between the write on the server and the completion of the next background revalidation. The value of \(s\) trades network traffic against freshness directly: larger \(s\) means fewer refetches and staler data; \(s = 0\) means every access is considered stale and revalidates, maximizing freshness at the cost of a background request on every mount. Deduplication ensures that many components requesting the same key within a short window collapse to a single network request, which is what makes colocated fetching (below) affordable.
// TanStack Query: server state as a first-class cache.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function Comments({ postId }: { postId: string }) {
const qc = useQueryClient();
const { data, isPending, isError } = useQuery({
queryKey: ["comments", postId],
queryFn: () => fetch(`/api/posts/${postId}/comments`).then((r) => r.json()),
staleTime: 30_000, // fresh for 30 s; within that window, no refetch on mount
});
const add = useMutation({
mutationFn: (body: string) =>
fetch(`/api/posts/${postId}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
}).then((r) => r.json()),
// On success, invalidate the query so it revalidates against the server.
onSuccess: () => qc.invalidateQueries({ queryKey: ["comments", postId] }),
});
if (isPending) return <p>Loading…</p>;
if (isError) return <p>Failed to load comments.</p>;
return (
<ul>{data.map((c: { id: string; body: string }) => (
<li key={c.id}>{c.body}</li>
))}</ul>
);
}
// A minimal stale-while-revalidate cache, to show the mechanism.
type Entry<T> = { value: T; fetchedAt: number };
const cache = new Map<string, Entry<unknown>>();
const inflight = new Map<string, Promise<unknown>>();
async function swr<T>(
key: string,
fetcher: () => Promise<T>,
staleMs: number,
): Promise<T> {
const now = Date.now();
const hit = cache.get(key) as Entry<T> | undefined;
const revalidate = () => {
if (inflight.has(key)) return inflight.get(key) as Promise<T>; // dedupe
const p = fetcher().then((value) => {
cache.set(key, { value, fetchedAt: Date.now() });
inflight.delete(key);
return value;
});
inflight.set(key, p);
return p;
};
if (!hit) return revalidate(); // cold: must wait
if (now - hit.fetchedAt > staleMs) revalidate(); // stale: refresh in background
return hit.value; // serve immediately either way
}
The request waterfall, colocated fetching, and server actions
A subtle performance killer in component-based UIs is the client-side request waterfall. If a parent component fetches data, and only after it renders does a child component fetch its data (because the child was not rendered until the parent's fetch resolved), the two requests run in series when they could have run in parallel. Deeply nested components that each fetch in an effect produce a staircase of sequential round-trips, and each level adds a full network latency to the time-to-content.
There are two complementary fixes. Hoisting or preloading data
so all fetches are kicked off at once at the top, and letting the tree
render as data arrives, which frameworks like Remix do at the route
level by running all loaders for a route in parallel before rendering.
And colocation on the server: with RSC, a Server Component can
await its own data during render, and because it runs on
the server the round-trip is server-to-database (sub-millisecond)
rather than browser-to-server (tens to hundreds of milliseconds), so a
waterfall that would be painful on the client is negligible on the
server. This is a real and underappreciated benefit of RSC: it moves
the waterfall to where each step is cheap.
Server Actions close the loop for mutations. A server
action is a function marked "use server" that the client
can invoke directly, as if calling a local function, with the framework
handling the RPC. It replaces the ceremony of defining an API route,
fetching it with the right method and body, and parsing the response,
with a typed function call. Combined with optimistic updates
(useOptimistic) it gives instant-feeling mutations that
reconcile against server truth when the action resolves, which is what
the LikeButton example above demonstrates.
TypeScript at the API boundary
TypeScript's payoff is not uniform across a codebase; it is
concentrated at boundaries where assumptions cross a gap and can be
violated. The most important such boundary is between the client and
the server. Inside a single function, a type error is caught quickly by
tests or by running the code. At the network boundary, a mismatch, the
server started returning created_at as a number when the
client expects a string, is invisible until it produces a wrong render
in production. Types that span the boundary turn that runtime failure
into a compile error, which is where the leverage is.
Structural typing, generics, and discriminated unions
TypeScript is structurally typed: a value is assignable to a type if it
has the required shape, regardless of whether it was declared to
implement that type. This matches how data actually arrives over a
network, as anonymous JSON with a shape, and it is why modeling API
responses as plain object types works cleanly. Generics let a function
or type be parameterized over the shape it carries, which is how a fetch
wrapper can return Promise<User> or
Promise<Post> from the same code without
any.
The single most useful pattern at the boundary is the discriminated
union for modeling responses that can succeed or fail. A response is
either a success carrying data or an error carrying a code and message,
and a shared literal tag (status) lets the compiler narrow
the type after a check, so the data field is only accessible on the
success branch. This makes it impossible to read result.data
without first handling the error case, encoding the "check for errors
before using the result" discipline in the type system rather than in
code review.
// A discriminated union models "success or failure" so the compiler forces
// the caller to handle the error branch before touching the data.
type ApiResult<T> =
| { status: "ok"; data: T }
| { status: "error"; code: number; message: string };
function render(result: ApiResult<{ title: string }>) {
if (result.status === "error") {
return `Error ${result.code}: ${result.message}`;
// result.data is NOT accessible here; the union has narrowed to the error arm
}
return result.data.title; // only reachable on the "ok" arm
}
// zod gives a runtime validator AND a static type from one schema, so the
// parsed value's TYPE is guaranteed to match what was actually validated.
import { z } from "zod";
const User = z.object({
id: z.string().uuid(),
name: z.string().min(1),
createdAt: z.coerce.date(), // coerces the JSON string into a Date, validated
});
type User = z.infer<typeof User>; // { id: string; name: string; createdAt: Date }
// A route handler that validates untrusted input at the boundary. Parsing,
// not casting: the body is unknown until zod confirms its shape at runtime.
import { z } from "zod";
const CreatePost = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
tags: z.array(z.string()).max(10).default([]),
});
export async function POST(req: Request): Promise<Response> {
const parsed = CreatePost.safeParse(await req.json());
if (!parsed.success) {
// 400 with a structured, typed error the client can render field-by-field
return Response.json(
{ status: "error", code: 400, issues: parsed.error.flatten() },
{ status: 400 },
);
}
// parsed.data is now typed AND validated: title/body are non-empty strings
const post = await db.post.create({ data: parsed.data });
return Response.json({ status: "ok", data: post }, { status: 201 });
}
End-to-end type safety with tRPC and zod
The deepest form of boundary safety erases the boundary from the type
system's point of view. tRPC lets the server define procedures with
typed inputs and outputs, and the client imports the type of
the server's router (never its code) so that calling a procedure from
the client is fully typed end to end, with autocomplete on inputs and
inferred output types, and no hand-written client. There is no code
generation step and no schema file; the types flow directly from the
server definitions to the client through TypeScript's inference. zod
provides the runtime half: because the network cannot be trusted to
deliver the shape the types claim, the input is validated at runtime by
a zod schema, and that same schema produces the static type via
z.infer, so the runtime check and the compile-time type can
never drift apart. This "parse, don't validate" discipline, turning
unknown input into a typed value at the boundary and only
then letting it into the typed interior, is the pattern that makes the
interior safe to write with confidence.
Authentication and sessions
Authentication decisions live at the same trust boundary as the API, and the central design axis is where the session lives: on the server (stateful sessions) or in a self-describing token the client carries (stateless tokens). This section covers the frontend-visible mechanics; the cryptographic and threat-model depth belongs to computer security, which treats token forgery, CSRF, and session fixation as attacks.
Cookies versus tokens, and the httpOnly rule
A cookie is a value the browser stores per origin and attaches
automatically to every request to that origin. A token is a value the
application stores and attaches deliberately, usually in an
Authorization header. The automatic attachment is both the
convenience and the danger of cookies: it is why sessions "just work"
across navigations, and it is why cross-site request forgery (CSRF) is
possible, because a malicious site can cause the browser to send a
request to your origin with the user's cookie attached.
Two flags make cookies safe enough to use for auth. HttpOnly
makes the cookie invisible to JavaScript, so a cross-site scripting
(XSS) payload cannot read the session cookie and exfiltrate it; this is
the decisive reason to keep session credentials in HttpOnly cookies
rather than in localStorage, which any script on the page
can read. Secure restricts the cookie to HTTPS.
SameSite controls whether the cookie is sent on cross-site
requests: SameSite=Lax (a common default) withholds the
cookie from most cross-site requests, which blunts CSRF, and
SameSite=Strict withholds it from all cross-site
navigation. Where cross-site requests are needed, an explicit anti-CSRF
token (the synchronizer or double-submit pattern) is the standard
defense, per the OWASP guidance.
JWT and its footguns
A JSON Web Token is a signed, base64url-encoded payload of claims: who
the user is, when the token expires, what it grants. Its appeal is that
it is stateless: the server can verify the signature and trust
the claims without a database lookup, which scales horizontally because
any server can validate any token. Its central weakness is the mirror
image of that appeal: because there is no server-side record, a JWT
cannot be revoked before it expires. If a token is stolen, or a user is
banned, or permissions change, the token remains valid until its
exp. The standard mitigation is the short-lived access
token plus long-lived refresh token pair: the access token expires in
minutes so the exposure window of a theft is small, and a refresh token
(which is checked against server state, so it can be revoked)
is exchanged for new access tokens. The other classic footguns are the
historical alg: none vulnerability, where a library
accepted an unsigned token, and algorithm-confusion attacks; both are
reasons to pin the expected algorithm explicitly and never trust the
token's own header to choose it.
A system issues access tokens with a 15-minute TTL and refresh tokens with a 7-day sliding TTL. (a) If an access token is stolen via a network trace, what is the maximum window an attacker can use it? (b) A user's admin privileges are revoked by editing the database at time \(t_0\); the access token was issued at \(t_0 - 5\) minutes. In a purely stateless JWT check, until when can the stale token still perform admin actions, and what is the general bound on this revocation lag? (c) If the app is continuously active, how many refreshes occur over the 7-day session, and what does the number say about where the real revocation checkpoint is?
Solution. (a) The token is valid until its
exp, which is issuance + 15 min. If it was stolen
immediately after issuance, the attacker has up to the full 15
minutes; if stolen later, correspondingly less. The exposure window is
bounded by the access-token TTL, which is precisely why it is kept
short.
(b) The token was issued at \(t_0 - 5\) min with a 15-minute TTL, so
it expires at \(t_0 + 10\) min. A stateless check only verifies the
signature and exp, both still valid, so the revoked
admin can act until \(t_0 + 10\) min, a 10-minute revocation lag. In
general the lag is bounded above by the access-token TTL: the worst
case is a revocation occurring an instant after a fresh token is
issued, giving a lag of the full TTL, here 15 minutes.
(c) A 7-day window with a new access token every 15 minutes is \(7 \times 24 \times 60 / 15 = 10080 / 15 = 672\) refreshes. Each refresh consults the refresh token, which is stateful and revocable. So the real revocation checkpoint is the refresh event: revoking the refresh token guarantees the session ends within at most one access-token TTL (15 minutes), because the next refresh fails and no new access token is minted. This is the design's whole point: it confines the unavoidable statelessness of JWTs to a small, bounded window and puts revocation authority at the refresh boundary, which is checked 672 times over the session rather than never.
Performance and Core Web Vitals
Core Web Vitals are Google's field metrics for user-perceived quality,
measured at the 75th percentile of real page loads, documented on
web.dev and instrumented by the web-vitals library. There
are three, and each has a precise definition and threshold that a
practitioner should know exactly.
| Metric | What it measures | Good (p75) | Needs work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | time until the largest content element in the viewport is painted | ≤ 2.5 s | 2.5–4.0 s | > 4.0 s |
| INP (Interaction to Next Paint) | the worst (near-worst) latency from an interaction to the next paint, across the whole visit | ≤ 200 ms | 200–500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | sum of unexpected layout-shift scores over the page's life | ≤ 0.1 | 0.1–0.25 | > 0.25 |
The most important recent change: in March 2024, INP replaced First Input Delay (FID) as the Core Web Vitals responsiveness metric. FID measured only the delay before the browser began processing the first interaction, which was easy to pass and did not reflect sustained responsiveness. INP considers essentially all interactions across the visit and reports a near-worst-case latency from input to the next visual update, so it captures a janky dropdown on the tenth click that FID ignored. Moving each metric requires different work:
- LCP is a network-and-render problem. It is moved by reducing time-to-first-byte (caching, edge, faster server), by making the LCP resource discoverable early (preload the hero image, avoid hiding it behind a lazy-loaded component), by not blocking render on unnecessary CSS/JS, and by serving appropriately sized, modern-format images.
- INP is a main-thread problem. It is moved by shipping less JavaScript (so the main thread is free to respond), by breaking long tasks so the browser can paint between them, by using transitions to keep expensive updates from blocking input, and by avoiding layout thrashing in event handlers.
- CLS is a layout-stability problem. It is moved by
reserving space for anything that loads late, images with explicit
width/heightor an aspect-ratio box, ad and embed slots with reserved dimensions, and by never inserting content above existing content after the user can see it, and by usingfont-displaysettings that avoid a reflow when a web font swaps in.
A page must hit LCP \(\le\) 2.5 s at the 75th percentile on the
standard emulated "Slow 4G" profile: 1.6 Mbps throughput, 150 ms
round-trip time. Assume DNS is uncached, so connection setup is DNS +
TCP + TLS 1.3 \(\approx\) 3 round-trips; the server takes 200 ms to
produce the first byte after the request arrives; the HTML is 15 KB;
a render-blocking stylesheet is 25 KB; and the LCP element is a hero
image that can only be requested after the CSSOM is built (one more
round-trip to issue the request). Compute the fixed overhead before
the image download begins, and the largest hero image that still
fits under 2.5 s. Then compute how much a single preload
hint on the image saves.
Solution. Throughput \(r = 1.6\times 10^6/8 = 200\) KB/s. Round-trip \( \text{RTT} = 0.150\) s.
Connection setup: \(3 \times 0.150 = 0.450\) s.
Time to first byte = connection + server think + one RTT for the request/first-byte = \(0.450 + 0.200 + 0.150 = 0.800\) s.
HTML download: \(15/200 = 0.075\) s = 75 ms (call it 76.8 ms at exact KB=1024; using 1000 for clarity, 75 ms).
After HTML, one RTT to fetch the CSS request in flight is overlapped by the parser, but the CSS must download before CSSOM: \(25/200 = 0.125\) s = 125 ms, plus one RTT (0.150 s) to issue the image request after CSSOM.
Fixed overhead before the image bytes flow: \(0.800 + 0.075 + 0.150 + 0.125 + 0.150 = 1.300\) s (matching the exact-KB computation of 1.305 s).
Remaining budget: \(2.5 - 1.300 = 1.200\) s. Largest image: \(1.200 \times 200\ \text{KB/s} = 240\) KB. So a hero image up to roughly 233–240 KB fits; a 300 KB hero pushes LCP to about \(1.300 + 300/200 = 1.300 + 1.500 = 2.800\) s, over budget.
With a preload on the image in the HTML head, the
browser starts the image fetch as soon as the HTML is parsed,
overlapping it with CSS instead of waiting the extra RTT after
CSSOM. That removes one 150 ms round-trip from the fixed overhead,
raising the image budget to \((1.200 + 0.150)\times 200 = 270\) KB,
and it drops the 300 KB hero's LCP to about 2.69 s, still over but
far closer. The lesson: on a constrained link, fixed round-trip
overhead consumes more than half the LCP budget before a single
image byte arrives, so the highest-leverage moves are cutting
round-trips (edge TTFB, preconnect, preload) and shrinking the
critical bytes, not micro-optimizing the render.
Edge deployment and caching
The last lever on latency is physical: put the response near the user. A content delivery network replicates content across points of presence worldwide, so a request travels to a nearby edge rather than to a single origin, cutting the round-trip time that dominated the LCP budget above. Edge functions extend this from static files to compute, running lightweight logic (personalization, auth checks, A/B assignment) at the edge close to the user rather than at a distant origin.
Cache-Control and stale-while-revalidate
HTTP caching is governed by the Cache-Control response
header, and the frontend-relevant directives are worth knowing exactly.
max-age=N makes a response fresh for \(N\) seconds in any
cache; s-maxage=N does the same but only for shared caches
like a CDN, letting the edge cache longer than the browser.
stale-while-revalidate=N is the header form of the same
policy React Query uses: for \(N\) seconds after the response goes
stale, a cache may serve the stale copy immediately while
revalidating in the background, so the user never waits on the origin at
the moment of expiry. stale-if-error=N serves stale content
if the origin errors, a cheap availability win. This header is what makes
ISR work at the CDN layer, and it is the single most useful caching
directive to understand because it breaks the false choice between
"fast but stale" and "fresh but slow".
# Inspect the caching contract a CDN is serving. -I fetches headers only.
curl -sI https://example.com/blog/post-42 | grep -iE 'cache-control|age|x-cache'
# A page using ISR-style edge caching typically returns something like:
# cache-control: public, s-maxage=60, stale-while-revalidate=600
# age: 37 <- seconds this copy has lived in the edge cache
# x-cache: HIT <- served from the edge, no origin round-trip
#
# s-maxage=60 : the shared (CDN) cache treats it as fresh for 60 s
# stale-while-revalidate=600 : for 600 s AFTER that, serve stale instantly and
# refresh in the background; the user never blocks
#
# Drive a load and watch the hit ratio climb as the edge warms:
for i in $(seq 1 20); do
curl -sI https://example.com/blog/post-42 | grep -i x-cache
done | sort | uniq -c # count HIT vs MISS across 20 requests
# Core Web Vitals are FIELD metrics; the web-vitals JS library reports them
# from real sessions. A common pipeline: the browser POSTs each metric to a
# collector, and you aggregate at the 75th percentile (the CWV reporting point).
import numpy as np
# Example: LCP samples (ms) collected from real users over a day.
lcp_ms = np.array([1800, 2100, 2400, 2600, 2900, 3100, 2200, 1950,
2750, 2300, 2050, 3400, 2500, 2650, 2150, 2450])
p75 = np.percentile(lcp_ms, 75)
print(f"LCP p75 = {p75:.0f} ms") # the number Google grades
print("passes 'good' (<= 2500 ms):", p75 <= 2500)
# INP is scored similarly but from interaction latencies, and the threshold
# for 'good' is 200 ms. CLS is unitless with a 0.1 'good' threshold.
inp_ms = np.array([90, 120, 180, 220, 160, 300, 140, 110, 250, 175])
print(f"INP p75 = {np.percentile(inp_ms, 75):.0f} ms (good <= 200)")
An edge caches one popular object with Cache-Control: public,
s-maxage=60, stale-while-revalidate=600. Requests for it arrive
at a steady 5 per second. Assuming the object stays continuously hot
(so it is revalidated roughly once per fresh window rather than
expiring out of cache), compute over one hour: the total requests, the
number of origin fetches, the origin offload the edge provides, and the
origin's request rate. Then explain what stale-while-revalidate
changes about the latency experienced on the revalidating
requests, and what request coalescing at the edge adds.
Solution. Requests per hour: \(5 \times 3600 = 18000\).
With s-maxage=60, the object is fresh for 60 s at a
time, so it needs revalidating once per 60-second window. In one
hour that is \(3600/60 = 60\) origin fetches.
Origin offload / cache-hit ratio (from the user's perspective, every request is served from the edge): \(1 - 60/18000 = 1 - 0.00333 = 99.667\%\). Origin request rate: \(60/3600 = 0.0167\) requests per second, one request every 60 seconds, regardless of the 5 req/s of user traffic.
What stale-while-revalidate changes: without it, the
request that arrives just after the 60 s freshness expires would
block on the origin fetch and pay full origin latency (say 300 ms),
once per window. With it, that request is served the stale copy
immediately (edge latency, a few ms) and the origin fetch happens in
the background, so no user request ever waits on the origin.
The 60 origin fetches per hour still happen; they just no longer sit
on the critical path of any user.
Request coalescing (collapsed forwarding) matters at the moment of
revalidation: if the edge did not coalesce, a burst of requests
arriving during the brief background-refresh window could each
trigger their own origin fetch, multiplying the 60 into a spike.
Coalescing collapses concurrent misses for the same key into a single
origin request, which is what keeps the origin rate at a flat
\(0.0167\) req/s even under bursty traffic. The combined effect,
high hit ratio, no user-visible revalidation latency, and a
coalesced origin, is why a correctly configured
stale-while-revalidate is the backbone of both CDN
caching and ISR.
Accessibility as a correctness property
The product design page treats accessibility as a design concern, deciding contrast, focus order, and affordances up front. This page treats it as a correctness property of the implementation: an interface that a keyboard or screen-reader user cannot operate is as broken as one that throws an exception, and it is broken for a population that includes every user on a bad trackpad or a broken mouse. The implementation discipline reduces to a few rules with real teeth.
Semantic HTML first. A <button> is
focusable, is announced as a button, fires on Enter and Space, and is in
the tab order, all for free. A <div onClick> has none
of that and must reconstruct every piece by hand, and usually does so
incompletely. Using the right element, <button>,
<a>, <nav>, <main>,
<label> tied to its input, is the single highest-value
accessibility practice, and it is free because the platform implements
the behavior.
ARIA only when the platform has no element for it. The
first rule of ARIA, stated in the WAI-ARIA authoring practices, is not to
use ARIA if a native element will do, because a native element's behavior
is real and an ARIA role is only a promise the developer must then
implement. ARIA is for the cases the platform genuinely lacks, a custom
combobox, a tab set, a live region announcing async updates, and there it
is essential, but a role="button" on a div still needs all
the keyboard handling written by hand and is strictly worse than a button.
Keyboard and focus are non-negotiable. Every
interactive element must be reachable and operable by keyboard, focus
must be visible (never outline: none without a replacement),
and focus must be managed across dynamic changes: when a modal opens,
focus moves into it and is trapped there; when it closes, focus returns
to the trigger. These are the failures that lock out keyboard users
entirely, and they are invisible to anyone testing only with a mouse,
which is why keyboard-only testing is part of the definition of done, not
an afterthought.
Implementation: modern CSS and the render path
Two implementation examples that connect the theory to concrete markup.
The first is a container query, which lets a component adapt to the width
of its own container rather than the viewport, the correct primitive for
reusable components since the same card can appear in a wide main column
or a narrow sidebar and should lay out based on where it is, not on the
window. Container queries and the :has() relational selector
are the two modern CSS features that most changed what is expressible
without JavaScript. The second shows the render-blocking behavior
discussed in the critical rendering path.
/* The parent declares itself a query container on its inline (width) axis. */
.card-list {
container-type: inline-size;
container-name: cards;
}
/* The card lays out based on the CONTAINER's width, not the viewport's, so the
same component is single-column in a sidebar and two-column in a wide slot. */
.card {
display: grid;
grid-template-columns: 1fr; /* narrow default */
gap: 0.5rem;
}
@container cards (min-width: 480px) {
.card { grid-template-columns: 8rem 1fr; } /* image beside text when roomy */
}
/* :has() styles a parent based on its children with no JS: dim a card that
contains a sold-out badge. */
.card:has(.badge--sold-out) { opacity: 0.5; }
/* Reserve space to keep CLS at zero: the box holds its ratio before the image
loads, so nothing shifts when it arrives. */
.card img { aspect-ratio: 16 / 9; width: 100%; height: auto; }
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<!-- Render-blocking: the browser will not paint until this is fetched
and the CSSOM is built. Keep it small and above the fold. -->
<style>
/* inlined critical CSS: no extra round-trip before first paint */
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; }
.hero { min-height: 40vh; }
</style>
<!-- Warm the connection to a third-party origin before it is needed. -->
<link rel="preconnect" href="https://images.example.com" />
<!-- Discover the LCP image early instead of after CSSOM: saves one RTT. -->
<link rel="preload" as="image" href="https://images.example.com/hero.avif"
fetchpriority="high" />
<!-- defer: runs after parsing, in order, without blocking the parser. -->
<script defer src="/app.js"></script>
</head>
<body>
<main>
<!-- width/height reserve layout space so the image cannot cause a
layout shift (CLS) when it loads. -->
<img class="hero" src="https://images.example.com/hero.avif"
width="1600" height="900" alt="Product on a desk" />
</main>
</body>
</html>
How it is done in practice
The gap between the derivations above and a deployed system is mostly about defaults and measurement. Production frontends converge on a small set of patterns. The rendering model is chosen per route, not per app: a marketing homepage is statically generated, a product page uses ISR with a short revalidation window, a logged-in dashboard is client-rendered behind auth, and a search results page streams. Frameworks like Next.js and Remix (now merged into React Router) make this per-route choice the unit of configuration precisely because one app spans all of these.
Measurement is field-first. Lab tools (Lighthouse, running a headless
Chrome with the throttling profile used in Problem 4) are for catching
regressions in CI and for diagnosing, but the numbers that matter are
the field metrics from real users at p75, collected by the
web-vitals library and reported through the Chrome User
Experience Report. A page that scores well in the lab on a fast machine
and a fast link can still fail in the field, because the field includes
the mid-range phone on the congested link that the lab throttling only
approximates. The discipline is to set a performance budget (a byte
budget on the critical bundle, a p75 target on each vital) and to fail CI
when a change blows it, so regressions are caught at the pull request
rather than in production.
On the delivery side, the modern default is to serve from an edge CDN
with stale-while-revalidate caching, to ship images in AVIF
or WebP at correct dimensions with loading="lazy" on
below-the-fold images and a preload on the LCP image, to split the
JavaScript bundle by route so a page only loads its own code, and to keep
the critical bundle under a budget in the low tens of kilobytes. The
build tooling that makes this affordable, Vite for the dev server and
bundling, esbuild and SWC for fast transpilation, is itself part of the
story: a fast dev loop and fast production builds are what make the
per-route optimization work practical rather than aspirational.
The current research and engineering frontier
The active frontier, roughly 2022 through 2025, is a set of competing answers to the same question: how to deliver interactivity without paying for a full client-side framework on every page. React Server Components (Meta, shipped through Vercel's Next.js) is the most widely adopted answer, moving component execution to the server and shrinking the client to islands. Resumability (Qwik, from the team formerly behind Angular's early work) is the most aggressive, eliminating hydration re-execution entirely by serializing state and lazy-loading handlers. The islands architecture (Astro) takes a framework-agnostic middle path, letting components from React, Vue, or Svelte hydrate independently on an otherwise static page.
Svelte's compiler approach (from a line of work started by Rich Harris) is a different bet again: compile components to imperative DOM updates at build time so there is no virtual-DOM diffing at runtime, trading framework flexibility for a smaller runtime, and Svelte 5's "runes" bring fine-grained reactivity in the style of Solid (Ryan Carniato), which tracks dependencies at the signal level rather than re-running components. Signals as a reactivity primitive have spread across the ecosystem (Solid, Preact Signals, Angular, Vue's reactivity) and are a genuine point of divergence from React's re-render-and-diff model. On the standards side, the View Transitions API is bringing animated transitions between page states and across navigations into the platform itself, and Speculation Rules let the browser prerender likely next navigations, both moving capabilities that once required a framework into the browser. The through-line is that the interesting work is in reducing or eliminating the JavaScript tax, and the competition is over which architecture pays the least for a given amount of interactivity.
Open source to read
- facebook/react —
the reference implementation of the reconciler, hooks, Suspense, and
RSC. Start with
packages/react-reconcilerto see the fiber work loop; the RSC serialization lives inpackages/react-server. - vercel/next.js —
the App Router, RSC integration, ISR, and streaming in production form.
Read the
packages/next/src/serverrender path to see how a request becomes a streamed response. - remix-run/react-router — the loader/action data model and parallel route-level data fetching that avoids the client waterfall; Remix's approach now lives here. Open the data-router docs and the loader implementation.
- TanStack/query —
the canonical server-state cache. Read
packages/query-corefor the cache, the query observer, and the stale-while-revalidate logic worked through above. - colinhacks/zod —
schema validation that produces both a runtime validator and a static
type. The core parsing logic in
src/types.tsshows howz.inferandsafeParsestay in sync. - trpc/trpc — end-to-end type safety with no codegen. Read the router and the client proxy to see how the server's types reach the client purely through inference.
- honojs/hono — a small, fast, standards-based web framework that runs on the edge runtimes; a clean codebase for understanding the request/response model and middleware.
- vitejs/vite — the dev server and build tool most of this ecosystem uses; read how native ES modules and esbuild give a near-instant dev loop.
- GoogleChrome/lighthouse — the lab auditing tool; the audits directory documents exactly how each Core Web Vital is computed and what triggers each opportunity.
- GoogleChrome/web-vitals — the tiny library that measures LCP, INP, and CLS in the field; read it to see precisely how each metric is derived from browser performance entries.
Common misconceptions
"Server-side rendering makes a page interactive faster." SSR makes a page visible faster, not interactive faster. The HTML paints early, but the page is inert until the JavaScript downloads and hydration completes, and hydration can actually delay interactivity relative to what the early paint suggests, the "uncanny valley" where the page looks ready but ignores clicks. What makes a page interactive faster is shipping less JavaScript, which is RSC and islands, not SSR by itself.
"A single-page app is faster because navigation is client-side." Client-side navigation is fast after the first load, but the first load pays for the whole bundle before anything is usable, and that first load is the impression that matters for a new visitor and for search ranking. Whether an SPA is faster depends entirely on the ratio of first loads to in-app navigations, which for content-oriented sites is the wrong way for SPAs.
"Wrapping things in useMemo makes React faster." Memoization has a cost, memory plus a dependency comparison every render, and it only pays when the memoized work is genuinely expensive or prevents a costly downstream re-render. Applied reflexively it adds overhead and obscures the code. Measure first; the React Compiler now does most of this automatically and more correctly than hand application.
"Storing the JWT in localStorage is fine." Anything in
localStorage is readable by any JavaScript on the page, so a
single XSS payload exfiltrates the session. Session credentials belong in
an HttpOnly cookie, invisible to script, with
SameSite and Secure set. The convenience of
reading the token in JS is not worth turning every XSS into a full account
takeover.
"JWTs let you log users out instantly." A stateless JWT
cannot be revoked before it expires, because nothing is checked against
server state at verification time. "Logout" on the client only deletes the
local copy; a stolen copy remains valid until exp. Genuine
revocation requires either a short TTL with a stateful refresh token or a
server-side denylist, which reintroduces the state JWTs were meant to
avoid.
"Animating left and transform is the
same, one is just newer syntax." They produce the same motion but
not the same cost. Animating left changes geometry and forces
layout and paint on the main thread every frame; animating
transform is handled on the compositor thread with no layout
or paint. Under main-thread load the first janks and the second holds 60
fps.
"A high Lighthouse score means the site is fast." Lighthouse is a lab measurement on a simulated device and link; Core Web Vitals are graded on field data from real users at p75. A site can score 100 in the lab and fail in the field because real users are on slower devices and networks, or because the interactions that INP measures happen in flows the lab run never exercises. The lab tool is for diagnosis and regression-catching; the field is the scoreboard.
Self-check
References
- MDN Web Docs. "Critical rendering path", "Populating the page: how browsers work", and the CSS, DOM, and HTTP references. Mozilla, continuously updated. developer.mozilla.org.
- WHATWG. HTML Living Standard (parsing, the event loop, scripting) and DOM Living Standard. html.spec.whatwg.org, dom.spec.whatwg.org.
- W3C. CSS Containment, CSS Containment Level 3 (container queries), and the Selectors Level 4 (
:has()) specifications. w3.org/Style/CSS. - W3C. Resource Hints and Preload specifications. w3.org/TR/resource-hints.
- React documentation. react.dev, including "Thinking in React", the hooks reference, Suspense,
use, anduseTransition. Meta, continuously updated. react.dev. - Comeau, J., Clark, D., et al. React Server Components RFC. React team, 2020–2023. reactjs/rfcs #0188.
- web.dev. "Core Web Vitals", "Largest Contentful Paint (LCP)", "Cumulative Layout Shift (CLS)", and "Optimize long tasks". Google, continuously updated. web.dev/articles/vitals.
- Walton, P., and Wagner, A. "Interaction to Next Paint (INP)"; INP became a Core Web Vital replacing FID in March 2024. web.dev, 2023–2024. web.dev/articles/inp.
- Bishop, M. (ed.). HTTP/3. RFC 9114, IETF, 2022. rfc-editor.org/rfc/rfc9114.
- Iyengar, J., and Thomson, M. (eds.). QUIC: A UDP-Based Multiplexed and Secure Transport. RFC 9000, IETF, 2021. rfc-editor.org/rfc/rfc9000.
- Fielding, R., Nottingham, M., and Reschke, J. (eds.). HTTP Caching. RFC 9111, IETF, 2022 (Cache-Control,
stale-while-revalidateper RFC 5861). rfc-editor.org/rfc/rfc9111. - Grigorik, I. High Performance Browser Networking. O'Reilly, 2013 (free online). hpbn.co.
- Nielsen, J. "Response Times: The 3 Important Limits." Nielsen Norman Group, 1993 (from Usability Engineering). nngroup.com.
- Osmani, A. Learning JavaScript Design Patterns, 2nd ed. O'Reilly, 2023 (free online). patterns.dev.
- Microsoft. TypeScript Handbook (structural typing, generics, narrowing, discriminated unions). typescriptlang.org/docs.
- Vercel. Next.js documentation (App Router, Server Components, ISR, streaming, Server Actions). nextjs.org/docs.
- Remix / React Router documentation (loaders, actions, parallel data fetching). reactrouter.com.
- OWASP. Cross-Site Request Forgery Prevention Cheat Sheet and JSON Web Token for Java / Session Management Cheat Sheets. cheatsheetseries.owasp.org.
- Jones, M., Bradley, J., and Sakimura, N. JSON Web Token (JWT). RFC 7519, IETF, 2015. rfc-editor.org/rfc/rfc7519.
- W3C WAI. WAI-ARIA Authoring Practices Guide ("No ARIA is better than bad ARIA") and the WCAG 2.2 guidelines. w3.org/WAI/ARIA/apg.
- TanStack Query documentation (query keys, stale time, invalidation, deduplication). tanstack.com/query.
- zod and tRPC documentation (schema-derived types, end-to-end inference). zod.dev, trpc.io.
Every frontend decision is an allocation of work across a boundary the
developer can reason about but not remove: bytes must cross a network,
and code must run on a device, before a pixel appears. The critical
rendering path fixes the stages, DOM and CSSOM to render tree to layout
to paint to composite, and tells you that layout and paint are expensive
while compositor transforms are cheap, which is why you animate
transform and batch your reads. The rendering models are not
a menu to pick from by taste but a set of points chosen by how often
content changes and how personal it is, and React Server Components win
where a page is mostly static because the bundle math (57% less shipped
in the worked case) and the hydration cost model both reward shipping
less JavaScript. Server state deserves a cache with stale-while-revalidate
semantics, the same policy that at the HTTP layer keeps a CDN both fast
and fresh with a 99.7% hit ratio and no user ever blocking on the origin.
Types pay off at the boundary, where a discriminated union and a
zod-validated parse turn a production surprise into a compile error, and
JWT sessions must respect that a stateless token cannot be un-issued, so
revocation lives at the short-TTL refresh checkpoint. Core Web Vitals,
LCP at 2.5 s, INP at 200 ms since it replaced FID in March 2024, CLS at
0.1, are the scoreboard, and on a throttled link more than half the LCP
budget is gone to round-trips before an image byte arrives, which is why
the highest-leverage work is cutting round-trips and critical bytes. And
the quiet baseline under all of it: for content that is read far more
than it changes, the right model is often the simplest one, static files
from an edge, hydration cost zero, which is exactly what this site is.