../

Rendering strategies

Where and when HTML gets made (the server at build time, the server per request, or the browser) decides TTFB, first paint, how much JavaScript ships and how long the page takes to become interactive. This sheet compares the strategies and the React and Next.js levers for each. Framework details are on Next.js and React.

The strategies at a glance

CSR SSR SSG Streaming download + run JS fetch data server renders download JS + hydrate download JS + hydrate hydrate section by section slow parts stream in content late blue: server or CDN · purple: client JS · green bar: content visible · dot: interactive
When content appears and when it becomes interactive
StrategyHTML madeTTFBContent visibleJS / hydrationBest for
CSR (client-side rendering)in the browserfast (empty shell)late: after JS + datawhole appdashboards behind login, highly interactive tools
SSR (server-side rendering)per requestserver timeon first HTMLwhole page hydratespersonalized or fast-changing pages
SSG (static generation)at buildfastest (CDN)on first HTMLwhole page hydratesdocs, blogs, marketing
ISR (incremental static regeneration)at build, re-made in the backgroundfast (CDN)on first HTMLhydratesbig catalogs, content that changes hourly
Streaming SSRper request, sent in chunksfast (shell first)shell first, slow parts stream inprogressivepages mixing fast and slow data
PPR (partial prerendering)static shell at build + dynamic holes per requestfastest (CDN shell)shell immediatelyonly client componentsmost app pages (Next.js default under Cache Components)
Islands (Astro, Fresh)static or SSRfaston first HTMLonly interactive widgetscontent sites with a few widgets
Resumability (Qwik)SSRfaston first HTMLnone up front; handlers load on uselarge interactive pages where startup JS dominates

Most apps mix them per route: static marketing pages, a PPR product page, a CSR settings screen.

Hydration & its costs

Hydration = the client framework re-runs the components that produced the server HTML, rebuilds its tree, and attaches event listeners. Until it finishes, the page looks ready but ignores or delays input.

CostShows up asReduce with
Download + parse the component JSTBT, INP input delayServer Components, islands, smaller client components
Re-render work on the main threadlong tasks after loadfewer client components; lazy-hydrate below the fold
Serialized data for hydration (props, RSC payload)HTML size, TTFBpass only what the client needs
Hydration mismatchesa full client re-render, flickerdeterministic render: no Date.now()/Math.random()/window checks in render

React levers

ToolWhat it does for performance
Server Components (default in the App Router)render on the server; their code never ships to the client
"use client" boundarieseverything imported below one is client JS: place them low, on leaves
<Suspense>streams a fallback now and the content later; hydrates each boundary independently, prioritizing the one the user interacts with
useTransition / startTransitionmarks an update as non-urgent so typing and clicks interrupt it (helps INP)
useDeferredValuerenders an expensive child with a lagging value while the input stays responsive
React.lazy + import()splits a client component into its own chunk
React Compilermemoizes components and values automatically; removes most manual memo/useMemo/useCallback
memo, useMemo, useCallbackskip re-renders manually when the compiler isn't in use and profiling shows the need
List virtualization (TanStack Virtual)renders only visible rows of long lists
<Activity mode="hidden">keeps a hidden tab/panel's state and pre-renders it at low priority

Measure before memoizing: React DevTools → Profiler ("Highlight updates when components render") shows what re-renders and why.

Next.js levers

LeverEffectDetails
Cache Components + "use cache"cached work goes into the static shell; dynamic parts streamNext.js § Rendering
<Suspense> / loading.tsxstream slow segments; instant navigation to a loading state
next/imageresizes, serves WebP (add AVIF with images.formats), lazy by default; fetchPriority="high" or loading="eager" on the LCP image (preload replaced priority in 16)Next.js § Images & fonts
next/fontself-hosts fonts at build, preloads them, generates metric-matched fallbacks (much less CLS)
next/scriptstrategy="afterInteractive" (default), "lazyOnload", "beforeInteractive"; "worker" (Partytown; experimental, Pages Router only)
<Link> prefetchingprefetches routes in the viewport (static parts fully, dynamic up to the nearest loading)
optimizePackageImportsimports only the used modules from barrel-file libraries
after()runs logging/analytics after the response is sent, off the critical path

Edge vs origin & caching layers

LayerHoldsInvalidate with
Browser HTTP cacheassets, sometimes HTMLnew URLs (hashed filenames), no-cache on HTML
CDN / edge cachestatic HTML, ISR/PPR shells, assetsrevalidation (time or tag), purge APIs
Framework data cache ("use cache", fetch cache)results of data calls and componentscacheLife, revalidateTag, updateTag
Server memory / Rediscomputed results, sessionsTTLs, explicit deletes
Databasethe source of truth—

Rendering at the edge only helps if the data is nearby too. A server near the user that makes five round trips to a database on another continent is slower than one server next to the database. Put compute next to the data, cache the shell at the edge, and stream the rest.

Picking one

If the page…Use
is the same for everyone and changes rarelySSG
is the same for everyone, changes often, many URLsISR / "use cache" with a lifetime
is mostly static with a personalized corner (cart, greeting)PPR: static shell + a <Suspense> hole
depends on the request everywhere (search results, feeds)streaming SSR
lives behind login, is app-like and long-livedCSR or SSR + client state; SEO doesn't matter
is content with a few widgetsislands, or Server Components with small client leaves
needs SEO and fast LCPanything that sends real HTML: never CSR-only

Common mistakes

MistakeFix
"use client" at the top of the layoutpush it down to the interactive leaves
Fetching data in useEffect on a server-rendered pagefetch on the server; pass the data down
Awaiting every data source before sending anything<Suspense> around slow parts; stream
Sequential awaits that don't depend on each otherPromise.all, or start both then await
Rendering with Date.now() / window checkshydration mismatch; move to effects or the client
Huge props passed to client componentspass IDs and only needed fields
memo everywhere "just in case"the React Compiler, or profile first
Dynamic rendering for pages that could be staticcache the data ("use cache") so the page joins the shell
Edge functions calling a far-away databaserun near the data; cache at the edge

Recipes

Stream a slow section

The static shell sends immediately; the product and its recommendations stream in, each on its own. With Cache Components on, request-time reads (params not listed by generateStaticParams, uncached data) must sit inside <Suspense>, as here.

app/product/[id]/page.tsx
import { Suspense } from "react";
import { getProduct, getRecs } from "@/lib/data";
 
type Props = { params: Promise<{ id: string }> };
 
export default function Page({ params }: Props) {
  return (
    <main>
      <Suspense fallback={<h1>Loading…</h1>}>
        <Title params={params} />
      </Suspense>
      <Suspense fallback={<p>Loading picks…</p>}>
        <Recs params={params} />
      </Suspense>
    </main>
  );
}
 
async function Title({ params }: Props) {
  const { id } = await params;
  const product = await getProduct(id); // fast
  return <h1>{product.name}</h1>;
}
 
async function Recs({ params }: Props) {
  const { id } = await params;
  const recs = await getRecs(id); // slow
  return (
    <ul>{recs.map((r) => <li key={r.id}>{r.name}</li>)}</ul>
  );
}

Keep typing responsive while a list filters

The input updates immediately; the expensive list renders with a deferred value and can be interrupted.

search.tsx
"use client";
import { memo, useDeferredValue, useState } from "react";
 
export function Search({ items }: { items: string[] }) {
  const [query, setQuery] = useState("");
  const deferred = useDeferredValue(query);
  return (
    <>
      <input value={query}
        onChange={(e) => setQuery(e.target.value)} />
      <Results items={items} query={deferred} />
    </>
  );
}
 
const Results = memo(function Results(
  { items, query }: { items: string[]; query: string },
) {
  const hits = items.filter((i) => i.includes(query));
  return <ul>{hits.map((h) => <li key={h}>{h}</li>)}</ul>;
});

Parallel data on the server

Start independent requests together instead of one after another.

lib/page-data.ts
import { getUser, getOrders, getNews } from "./data";
 
export async function loadDashboard(userId: string) {
  const [user, orders, news] = await Promise.all([
    getUser(userId),
    getOrders(userId),
    getNews(),
  ]);
  return { user, orders, news };
}

Client-only widget, loaded on demand

A heavy chart that renders only in the browser, in its own chunk, with a placeholder at its final size.

components/chart-slot.tsx
"use client";
import dynamic from "next/dynamic";
 
const Chart = dynamic(() => import("./chart"), {
  ssr: false,
  loading: () => <div style={{ height: 320 }} />,
});
 
export function ChartSlot({ data }: { data: number[] }) {
  return <Chart data={data} />;
}

References