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

StrategyWinsCosts
CSRfast SPA navigation, decent FCP/TTI if the bundle is controlledlarge bundles delay FCP/LCP/TTI; weak SEO
SSRless JS to reach interactivity, better SEO, budget left for client JSTTFB suffers with load and slow networks; full page loads still needed for some interactions
SSG / static renderingpre-rendered at build → fastest possible serve, CDN-friendlyone 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 spikesadded 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 SSRrenderToNodeStream() instead of renderToString(); 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 (or react-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 push sends 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 + preconnect cut DNS/connection latency for the most critical origins.
  • Partytown moves heavy third-party scripts off the main thread into a web worker.

Relationships


References