SSR #
Server-Side Rendering is the approach where the server produces complete HTML — already containing data — before sending it to the browser. Users see real content immediately after the response arrives, not a blank page waiting for JavaScript to finish executing. This makes SSR excel at SEO, first-load performance, and pages whose content needs search engine indexing. But SSR has its own costs: every request requires the server to render HTML, increasing computational load and infrastructure complexity. This article covers how SSR works in depth, the hydration concept, critical caching strategies, HTML streaming, and when SSR is the right choice versus CSR or Static Site Generation.
How SSR Works #
In SSR, the rendering responsibility is on the server — not the browser. The server receives a request, fetches the needed data, renders complete HTML, then sends it to the browser.
sequenceDiagram
participant B as Browser
participant S as SSR Server
participant DB as Database / API
B->>S: GET /products/laptop-gaming
S->>DB: Fetch product data, reviews, related products
DB-->>S: Data available
Note over S: Render complete HTML on the server<br/>(React.renderToString / template engine)
S-->>B: Complete HTML with content
Note over B: The user immediately sees content!<br/>No blank page.
B->>S: GET /app.js (hydration bundle)
S-->>B: JavaScript bundle
Note over B: Hydration: JavaScript "brings to life"<br/>the HTML already in the DOM
Note over B: Page fully interactive (event handlers attached)Compared to CSR, SSR provides visible content far faster:
SSR vs CSR timeline comparison for a product page:
SSR:
T=0ms: Browser sends the request
T=200ms: Complete HTML with product, price, description received
→ The user immediately sees content
T=500ms: JavaScript bundle loaded
T=700ms: Hydration finished → page fully interactive
CSR:
T=0ms: Browser sends the request
T=50ms: Empty HTML received → blank page
T=600ms: JavaScript bundle loaded → loading spinner
T=900ms: API call for product data finished
T=1000ms: Page fully interactive
FCP (First Contentful Paint) difference:
SSR: ~200ms
CSR: ~600ms
→ SSR is 3x faster at showing the first content
Hydration — The Bridge Between SSR and Interactivity #
Hydration is the process where JavaScript takes over the server-rendered HTML and adds event handlers, state, and interactivity. This is the key concept distinguishing SSR from plain HTML.
The hydration process:
1. The server renders HTML with data:
<div id="product-card">
<h1>Gaming Laptop X1</h1>
<span class="price">Rp 15,000,000</span>
<button>Add to Cart</button>
</div>
2. The browser receives and displays the HTML → users can already read the content
3. The JavaScript bundle loads and executes
4. React/Vue "hydrates" the existing HTML:
- Doesn't create a new DOM
- Attaches event listeners to existing elements
- Initializes state from the data already in the HTML
5. The page is now interactive (buttons clickable, etc.)
Potential hydration problems:
Hydration Mismatch:
Happens when the server-rendered HTML differs from
what React would render on the client
→ React discards the server HTML and re-renders from scratch
→ This eliminates SSR's benefits for initial interactivity
Common causes:
- Dates/times rendered differently on the server vs client
- Random values (Math.random()) differing on every render
- Conditions depending on window/document (browser-only)
- localStorage/sessionStorage access during render
// ANTI-PATTERN: accessing window during render (causes hydration mismatch)
func productCardAntiPattern(product Product) *Element {
// Error! window doesn't exist on the server
isDarkMode := matchMedia("(prefers-color-scheme: dark)")
return renderDiv(className(isDarkMode, "dark", "light"), nil)
}
// CORRECT: use useEffect for browser-only API access
// (React hooks are a framework API — this shows the equivalent browser logic)
func productCardCorrect(product Product) *Element {
state := &cardState{isDarkMode: false} // default for SSR — no server-side access
useEffect(func() {
// useEffect only runs in the browser, not on the server
state.isDarkMode = matchMedia("(prefers-color-scheme: dark)")
})
return renderDiv(className(state.isDarkMode, "dark", "light"), nil)
}
TTFB — SSR’s Main Bottleneck #
Time to First Byte (TTFB) is the time from request sent until the first response byte is received. In SSR, TTFB reflects the time the server needs to fetch data and render HTML. This is the most critical metric and the most common bottleneck.
flowchart LR
Request["Browser\nRequest"]
subgraph Server["Server Processing (TTFB)"]
Auth["Authentication\n& Session\n~10ms"]
DB["Database\nQuery\n~50-200ms"]
API["External API\nCall\n~100-500ms"]
Render["HTML\nRendering\n~10-50ms"]
end
Response["HTML\nResponse"]
Request --> Auth --> DB --> API --> Render --> Response
style DB fill:#E67E22,color:#fff
style API fill:#E74C3C,color:#fffGood TTFB target: < 600ms (Google recommendation)
Excellent TTFB target: < 200ms
What most often slows down TTFB:
1. Slow database queries or N+1 queries
Solution: optimize queries, add indexes, use connection pools
2. Slow external API calls
Solution: cache API responses, set strict timeouts, fall back to stale data
3. No server-level caching
Solution: cache rendered results or cache query data
4. Expensive authentication (decoding JWTs per request)
Solution: cache token validation results
How to measure TTFB:
- Chrome DevTools → Network tab → TTFB column
- Web Vitals extension
- server-timing headers for per-component breakdowns
Caching Strategies for SSR #
Caching is the most important thing for making SSR scalable. Without caching, every request forces the server to do a full render — which is computationally expensive.
SSR caching levels from closest to the user:
1. CDN / Edge Cache (most effective)
→ Cache rendered HTML at edge nodes (CloudFlare, Fastly)
→ Requests never reach the origin server
→ Very low latency (edge servers are usually geographically close)
Good for: pages identical for all users (products, articles, landing pages)
Not good for: personal pages (user dashboards, carts, profiles)
// Cache-Control header for CDNs:
Cache-Control: public, s-maxage=300, stale-while-revalidate=600
// Cache at the CDN for 5 minutes, serve stale while refreshing in the background for 10 minutes
2. Full Page Cache on the Server
→ Store rendered HTML results in Redis or memory
→ Subsequent requests for the same URL directly return cached HTML
// Example implementation in a Next.js API / Express:
const cache = new Map()
async function getProduct(id) {
const cacheKey = `product:${id}`
if (cache.has(cacheKey)) {
return cache.get(cacheKey)
}
const product = await db.fetchProduct(id)
cache.set(cacheKey, product)
setTimeout(() => cache.delete(cacheKey), 5 * 60 * 1000) // 5-minute TTL
return product
}
3. Data Cache (not storing HTML, but storing data)
→ Cache database query results or API responses
→ Rendering still happens, but with data already available in memory
Good for: data shared across many different pages
More flexible than full page caching
4. Stale-While-Revalidate
→ Serve old (stale) content while refreshing in the background
→ Users never wait for refreshes
→ Content may be slightly outdated, but responses are always fast
Ideal for: content updated frequently with tolerance for slight staleness
Example: stock prices updating every minute (1-2 minute delays tolerable)
Don’t cache pages containing personal user data like dashboards, profiles, or carts. Overly aggressive CDN caching can make user A’s data visible to user B — a serious security incident. Always include Vary: Cookie, Authorization headers for session-dependent pages, so CDNs treat each session as a different cache key.SSR vs SSG — When to Choose Which #
Static Site Generation (SSG) is an SSR variant where HTML is generated at build time, not request time. Both produce browser-displayable HTML, but with very different trade-offs.
flowchart TD
Q1{"How often does\nthe data change?"}
Q2{"Does content\ndiffer per user?"}
Q3{"How many\nunique pages?"}
SSG["Static Site Generation\nBuild HTML at deploy time\nFastest, cheapest\nExamples: blogs, docs, landing pages"]
ISR["Incremental Static\nRegeneration (ISR)\nRe-generate per interval\nNext.js: revalidate"]
SSR["Server-Side Rendering\nRender at request time\nFresh data, but has server costs"]
CSR["Client-Side Rendering\nData fetched in the browser\nFlexible for personal content"]
Q1 -->|"Never / rarely\n(blog posts, docs)"| SSG
Q1 -->|"Periodically\n(product prices, news)"| Q2
Q1 -->|"Real-time\n(live data, feeds)"| Q2
Q2 -->|"No (public content)"| Q3
Q2 -->|"Yes (per user)"| CSR
Q3 -->|"Many / unlimited\n(e-commerce catalogs)"| ISR
Q3 -->|"Limited\n(< 10,000 pages)"| SSGBrief comparison:
Method Build Time Request Time Cache SEO Personalization
SSG Slow Very fast Easy ✓✓✓ No
ISR Fast Fast Automatic ✓✓✓ No
SSR Fast Medium Manual ✓✓ Limited
CSR Fast Slow Client ✗ ✓✓✓
Per use case guidance:
Blog / Documentation → SSG
Marketing landing pages → SSG
E-commerce product catalogs → SSG + ISR
Product pages with stock → SSR or ISR with short revalidation
News portals → SSR or ISR
Personal dashboards → SSR (if SEO needed) or CSR
Social media feeds → CSR or SSR with streaming
HTML Streaming — Faster SSR #
One of traditional SSR’s weaknesses is that the server must finish rendering the entire page before sending the first byte. HTML streaming solves this by sending HTML incrementally — finished parts go out immediately, while parts depending on slow data follow later.
Traditional SSR:
The server fetches all data → renders all HTML → sends everything at once
The browser waits until the entire page is finished
SSR with Streaming:
The server immediately sends the HTML header + parts not needing data
→ The browser starts parsing and rendering the page shell
Meanwhile, the server fetches data for each section
→ When data is available, the server sends that section's HTML chunk
→ The browser displays that section
The total time users perceive: far faster
React 18 Suspense with Streaming:
// On the server (Next.js 13+ App Router):
export default async function ProductPage({ params }) {
// Render the page immediately with Suspense boundaries
return (
<main>
<ProductHeader /> {/* No data needed — sent immediately */}
<Suspense fallback={<ProductDetailSkeleton />}>
<ProductDetails id={params.id} /> {/* Fetches data, streams when ready */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} /> {/* Fetches data, streams when ready */}
</Suspense>
</main>
)
}
// ProductDetails fetches data directly:
async function ProductDetails({ id }) {
const product = await fetchProduct(id) // server-side fetch
return <div>...</div>
}
Partial Hydration and Islands Architecture #
One of SSR’s hidden costs is hydration — the browser must execute JavaScript for the whole page, even for non-interactive parts. Partial Hydration and Islands Architecture solve this.
Full Hydration (traditional SSR):
The entire page is hydrated with JavaScript
→ A 95%-static blog article still needs everything hydrated
→ Big JavaScript bundles, slow Time to Interactive
Partial Hydration / Islands Architecture:
Only interactive "islands" get hydrated
→ Navigation header (interactive) → hydrate
→ Article body (static) → no hydration needed
→ Comment section (interactive) → hydrate
→ Related articles (static links) → no hydration needed
Advantages:
→ Far less JavaScript sent to the browser
→ Much faster Time to Interactive
→ Great for content-heavy sites
Frameworks supporting Islands Architecture:
→ Astro (per-component islands)
→ Fresh (Deno-based)
→ Marko
→ Qwik (resumability, not hydration)
TTFB Optimizations for SSR #
// 1. Parallel data fetching — don't go sequential
// ANTI-PATTERN: sequential fetching
func getServerSidePropsSequential(params Params) {
product := fetchProduct(params.ID) // 200ms
reviews := fetchReviews(params.ID) // 150ms
related := fetchRelatedProducts(params.ID) // 100ms
// Total: 450ms sequential
}
// CORRECT: parallel fetching
func getServerSidePropsParallel(params Params) {
productCh := fetchProductAsync(params.ID) // 200ms
reviewsCh := fetchReviewsAsync(params.ID) // 150ms
relatedCh := fetchRelatedProductsAsync(params.ID) // 100ms
product := <-productCh
reviews := <-reviewsCh
related := <-relatedCh
// Total: ~200ms (the slowest fetch's time)
}
// 2. Timeouts for external dependencies
func fetchWithTimeout(url string, timeoutMs int) (any, error) {
result := make(chan any, 1)
go func() { result <- fetch(url) }()
select {
case res := <-result:
return res, nil
case <-time.After(time.Duration(timeoutMs) * time.Millisecond):
// Fall back to nil, render the page without this data
return nil, nil
}
}
// 3. Server-side caching for frequently accessed data
var productCache = map[string]any{}
func getCachedProduct(id string) any {
if product, ok := productCache[id]; ok {
return product
}
product := dbFindProduct(id)
productCache[id] = product
// TTL cleanup
time.AfterFunc(time.Minute, func() { delete(productCache, id) })
return product
}
SSR Anti-Patterns to Avoid #
Blocking Rendering on Non-Critical Data #
// ✗ Anti-pattern: the page can't render until all data is ready
func getServerSidePropsBlocking() Props {
mainContent := fetchMainContent() // critical — 100ms
recommendations := fetchAI() // not critical — 2000ms!
ads := fetchAds() // not critical — 500ms
// Users must wait 2600ms because recommendations are slow
return Props{MainContent: mainContent, Recommendations: recommendations, Ads: ads}
}
// ✓ Solution: render critical content immediately, load non-critical on the client
func getServerSidePropsSolution() Props {
mainContent := fetchMainContent() // only fetch the critical part
return Props{MainContent: mainContent}
// recommendations and ads fetched client-side using useEffect
}
No Timeouts for External Dependencies #
// ✗ Anti-pattern: external calls without timeouts
func getServerSidePropsUnsafe() {
fetch("https://external-review-service.com/reviews") // no timeout
// If this service is slow or down, the page hangs forever!
}
// ✓ Solution: always have timeouts and graceful fallbacks
func getServerSidePropsSafe() Props {
reviews, err := fetchWithTimeout("https://external-review-service.com/reviews", 1500) // 1.5 second timeout
if err != nil {
// Fallback: render the page without reviews
return Props{Reviews: []Review{}}
}
return Props{Reviews: reviews}
}
Overly Aggressive Caching of Personal Pages #
// ✗ Anti-pattern: Caching personal pages at the CDN
Cache-Control: public, s-maxage=3600
// User A sees user B's dashboard!
// ✓ Solution: Distinguish cache headers by page type
// Public pages (products, articles):
Cache-Control: public, s-maxage=300, stale-while-revalidate=600
// Personal pages (dashboards, profiles, carts):
Cache-Control: private, no-store
// Or: Cache-Control: no-cache (revalidate on every request)
Vary: Cookie, Authorization
When to Use SSR #
SSR is the right choice if:
✓ Public content needing SEO (blogs, e-commerce, news)
✓ First-load performance is critical (landing pages, homepages)
✓ Content shared on social media (needs meta tags in the initial HTML)
✓ Users on slow connections or low-end devices (no heavy JS parsing)
✓ Personalized content that still needs SEO (public profile pages)
SSR is less appropriate if:
✗ Highly interactive apps not needing SEO (admin panels, dashboards)
✗ Very rapidly changing data (real-time needing WebSockets)
✗ Teams without infrastructure to run Node.js servers / SSR runtimes
✗ Very limited infrastructure budgets (SSG is far cheaper for static content)
Consider SSG instead if:
✓ Content rarely changes (documentation, weekly-updated blogs)
✓ A limited number of pages buildable in reasonable time
✓ No per-user data needing server-side rendering
SSR Checklist #
PERFORMANCE:
□ TTFB monitored with a < 600ms target
□ Data fetching parallel, not sequential
□ Timeouts for all external dependencies
□ Server-side caching for frequently accessed data
CACHING:
□ CDN caching configured for public pages
□ Different Cache-Control headers for public vs private pages
□ Vary headers included for session-dependent pages
□ Stale-while-revalidate used for slightly-stale-tolerant content
HYDRATION:
□ No window/document access during render (only in useEffect)
□ Server and client render produce identical HTML
□ Suspense boundaries exist for slow-data content
□ Hydration mismatches monitored in production
SEO:
□ Meta tags (title, description, og:image) in the server-sent HTML
□ Structured data (JSON-LD) available in the initial HTML
□ Canonical URLs configured correctly
□ sitemap.xml and robots.txt available
RELIABILITY:
□ Error pages (404, 500) rendered properly on the server
□ Fallbacks available when data fetches fail
□ SSR doesn't crash when external services are down
MONITORING:
□ Server render times monitored (not just total response times)
□ Cache hit rates monitored
□ SSR error rates monitored separately from client errors
Summary #
- SSR sends complete HTML with data — browsers immediately display content without waiting for JavaScript, giving a much faster First Contentful Paint than CSR.
- Hydration adds interactivity to existing HTML — not creating a new DOM. Make sure server and client renders produce identical output to avoid hydration mismatches.
- TTFB is the most critical metric in SSR — every millisecond of server processing (DB queries, external APIs) is directly felt by users. Parallel data fetching and server-side caching are the best ways to improve it.
- Caching is the key to SSR scalability — without caching, every request forces a full render. CDN caching for public pages can reduce server load by almost 100%.
- Don’t cache personal pages at the CDN — this is a serious security issue. Use
Cache-Control: private, no-store for pages containing user data. - SSG beats SSR for rarely changing content — HTML is generated once at build time and can be served directly from CDNs without server involvement. Far faster and cheaper.
- ISR (Incremental Static Regeneration) fills the gap between SSR and SSG — generates static HTML but with the ability to re-generate periodically or on demand. Great for e-commerce catalogs.
- Streaming SSR reduces perceived loading time — send the page shell immediately, stream section-by-section as data becomes available. Users see progress, not a blank screen waiting for a full render.
- Partial Hydration reduces JavaScript sent to browsers — for content-heavy sites, not every part needs hydration. Only interactive components need JavaScript.
- Timeouts for all external dependencies are mandatory — SSR without timeouts on external API calls can hang entire pages if external services are slow or down.
#
- SSR sends complete HTML with data — browsers immediately display content without waiting for JavaScript, giving a much faster First Contentful Paint than CSR.
- Hydration adds interactivity to existing HTML — not creating a new DOM. Make sure server and client renders produce identical output to avoid hydration mismatches.
- TTFB is the most critical metric in SSR — every millisecond of server processing (DB queries, external APIs) is directly felt by users. Parallel data fetching and server-side caching are the best ways to improve it.
- Caching is the key to SSR scalability — without caching, every request forces a full render. CDN caching for public pages can reduce server load by almost 100%.
- Don’t cache personal pages at the CDN — this is a serious security issue. Use
Cache-Control: private, no-storefor pages containing user data. - SSG beats SSR for rarely changing content — HTML is generated once at build time and can be served directly from CDNs without server involvement. Far faster and cheaper.
- ISR (Incremental Static Regeneration) fills the gap between SSR and SSG — generates static HTML but with the ability to re-generate periodically or on demand. Great for e-commerce catalogs.
- Streaming SSR reduces perceived loading time — send the page shell immediately, stream section-by-section as data becomes available. Users see progress, not a blank screen waiting for a full render.
- Partial Hydration reduces JavaScript sent to browsers — for content-heavy sites, not every part needs hydration. Only interactive components need JavaScript.
- Timeouts for all external dependencies are mandatory — SSR without timeouts on external API calls can hang entire pages if external services are slow or down.