Web Rendering and Performance Patterns
Definition
Two decisions that dominate front-end performance: where the HTML is produced (client, server, build time, or edge) and in what sequence resources load. Both are judged by the same metrics, so they are one subject in practice.
Metrics vocabulary: FCP (First Contentful Paint), LCP (Largest Contentful Paint), TTI (Time to Interactive), FID (First Input Delay), TTFB (Time to First Byte).
Core Ideas
The rendering spectrum
| Strategy | Wins | Costs |
|---|---|---|
| CSR | fast SPA navigation, decent FCP/TTI if the bundle is controlled | large bundles delay FCP/LCP/TTI; weak SEO |
| SSR | less JS to reach interactivity, better SEO, budget left for client JS | TTFB suffers with load and slow networks; full page loads still needed for some interactions |
| SSG / static rendering | pre-rendered at build → fastest possible serve, CDN-friendly | one HTML file per route (a blog needs a rebuild per edit); depends heavily on hosting/edge caching; unsuitable for highly dynamic content |
| ISR (incremental static generation) | dynamic data without a full rebuild; at least as fast as SSG; a recent version is always online even if regeneration fails; one page at a time keeps backend load flat with no latency spikes | added mental model; staleness window |
The through-line: SSG solves CSR’s bundle problem, ISR solves SSG’s staleness problem.
Hydration patterns
- Progressive hydration — SSR everything, then hydrate chunks in a developer-defined sequence. Doesn’t block input on already-hydrated chunks; supports on-demand loading for rarely used page parts (distinct from lazy loading: the hydration code isn’t shipped at page load, it’s triggered). Smaller bundles → better LCP and TTI. Poor fit for apps where every element must be interactive immediately.
- Streaming SSR —
renderToNodeStream()instead ofrenderToString(); SEO plus performance plus backpressure handling. - Selective hydration (React 18) — stream with
pipeToNodeStream, fixing two Server Component limits: the whole tree had to be ready before sending, and React hydrated the tree only once (so all component JS had to arrive before any hydration). - Server Components — component code is never delivered to the client; backend access from anywhere in the tree (not just top-level
getServerProps); a server-rendered subtree can be refetched while preserving client state inside it (search results refresh without losing the input’s text, focus, or selection).
Streaming and progressive hydration are the bridge between pure SSR and pure CSR.
Islands architecture
Static HTML with isolated interactive “islands” hydrated independently.
- Wins: minimal JS shipped (only interactive components, not a full virtual DOM recreation) → faster loads and TTI; SEO-friendly; key content available almost immediately with interactivity arriving after; standard static links help accessibility; component-based reuse.
- Costs: you adopt a framework (Astro, Marko, Eleventy+Preact) or build it yourself; migration effort is real; little written discussion beyond the original post; unsuitable for highly interactive apps that would need thousands of islands.
Loading sequence and import strategy
Web vitals are directly dependent on the order critical resources load — a page cannot reach LCP while the hero image is unloaded.
Import strategies, from eager to deferred:
- Eager — the normal script load
- Lazy (route-based) — on navigation
- Lazy (on interaction) — on click; Google Docs defers ~500KB of share-feature script this way
- Lazy (in viewport) — via
IntersectionObserver(orreact-lazyload,react-loadable-visibility) - Prefetch — after critical resources, before needed
- Preload — eagerly, with urgency
Preload vs prefetch: a preloaded resource loads no matter what; with prefetch the browser still decides based on connection and bandwidth. Preload sparingly and measure in production — a badly placed preload delays FCP (competing with CSS and fonts), the opposite of the intent. Effectiveness also depends on the server prioritizing requests correctly.
Bundle and code discipline
- Code splitting — route-based (Next.js, React Router) and bundle splitting into small reusable pieces.
- Tree shaking — eliminate dead code via static
import/export. The catch is side effects: an ES6 module executes on import, so a module that touches global scope (polyfills, global stylesheets) cannot be shaken even when its exports are unused. - PRPL — push critical resources (minimize roundtrips), render the initial route ASAP, pre-cache frequently visited routes in the background (better offline too), lazily load infrequent routes/assets.
- HTTP/2 — requests split into frames over bidirectional streams;
server pushsends additional resources without an explicit request. Pushing too much is harmful: browser cache is limited and bandwidth is not free. - Compression — reduces transfer time but doesn’t fix a bad bundling strategy; granular chunking is a partial answer to the loading-granularity problem.
- List virtualization — render only rows inside the scroll viewport (
react-virtualized,react-window).
Streaming a response instead of waiting for it
The same FCP logic applies to a slow backend response, not just to assets. An LLM completion that takes seconds to finish can be streamed so text appears as it is generated, which is why nearly every chat UI does it: the perceived latency is the first token, not the last.
In Node/Express, proxying a stream is one line:
const streamResponse = await axios.get(sourceApiUrl, {responseType: 'stream'});
res.writeHead(200, {'Content-Type': 'text/plain'});
streamResponse.data.pipe(res);Modifying the stream in flight is where it gets interesting. A Transform handles chunked text:
const modifyStream = new Transform({
transform(chunk, encoding, callback) {
callback(null, chunk.toString().toUpperCase());
}
});
response.data.pipe(modifyStream).pipe(res);But a Transform breaks on structured output — function calls and JSON responses. JSON is not valid until its closing brace, so an unfinished chunk cannot be parsed, edited, and forwarded by ordinary means. The fix is a tolerant parser: best-effort-json-parser returns [1, 2, {a: 'apple'}] from [1, 2, {"a": "apple, and http-streaming-request wraps the whole pattern in an async iterator that yields progressively complete objects.
Two costs to accept: partial-JSON parsing is slower than a raw pipe (though rarely enough to matter unless the transform is heavy), and the iterator usually needs debouncing on top, or it emits a re-parse per chunk.
Third-party scripts
First-party JS often gets stuck behind non-critical third-party JS that keeps the main thread busy.
defer— fetched in parallel, executed after parsing. The default choice.async— fetched in parallel, executed as soon as available, blocking the parser. Use only for scripts that must run early (e.g. analytics that would otherwise miss page-load data).- Avoid synchronous third-party scripts in
<head>; load non-blocking third-party scripts after first-party JS. dns-prefetch+preconnectcut DNS/connection latency for the most critical origins.- Partytown moves heavy third-party scripts off the main thread into a web worker.
Relationships
- JavaScript Design Patterns — the other half of the same source reading; the module pattern is where dynamic import begins
- React — Server Components, selective hydration, and Concurrent Mode are React’s expression of these patterns
- Observability — Core Web Vitals are the front-end end of the same measurement discipline
- Backend for Frontend — rendering location and data-shaping decisions interact
- Designing to a Latency Budget — when the budget is hard rather than a target, it decides the architecture
- Software Engineering Practices
References
- JS Pattern — 2 Rendering Patterns
- JS Pattern — 3 Performance Pattern
- patterns.dev · web.dev metrics