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:#fff
Good 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)"| SSG
Brief 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.
#

← Previous: CSR   Next: SPA

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact