CSR #

Client-Side Rendering adalah paradigma di mana browser yang bertanggung jawab membangun tampilan halaman — server hanya mengirim HTML kosong, JavaScript bundle, dan data via API. Di era React, Vue, dan Angular, CSR menjadi default yang banyak tim adopsi tanpa benar-benar mempertimbangkan trade-off-nya. Ada kasus di mana CSR adalah pilihan terbaik: aplikasi yang sangat interaktif, dashboard internal, tools yang membutuhkan real-time update. Tapi ada juga kasus di mana CSR adalah pilihan yang salah: landing page yang butuh SEO, halaman produk e-commerce, konten yang perlu indexing mesin pencari. Artikel ini membahas cara kerja CSR secara mendalam, metrik performa yang terpengaruh, dan best practice agar CSR berjalan optimal.

Cara Kerja CSR #

Untuk memahami trade-off CSR, perlu dipahami dulu apa yang terjadi dari saat user membuka URL sampai halaman terlihat dan bisa digunakan.

sequenceDiagram
    participant B as Browser
    participant S as Server / CDN
    participant API as API Server

    B->>S: GET /dashboard
    S-->>B: HTML kosong + <script src="app.js">
    Note over B: Halaman masih putih (blank)

    B->>S: GET /app.js (bundle ~500KB)
    S-->>B: JavaScript bundle
    Note over B: Parse dan execute JavaScript (~200-500ms)

    B->>B: React/Vue bootstrap, render loading state
    Note over B: User melihat loading spinner

    B->>API: GET /api/user, GET /api/dashboard-data
    API-->>B: JSON response

    B->>B: Update state, re-render UI dengan data
    Note over B: Halaman fully interactive

    Note over B,API: Total waktu: 1.5–4 detik pada koneksi normal

Dibandingkan dengan Server-Side Rendering:

SSR — apa yang user lihat per tahap:
  T=0ms:    Request dikirim
  T=200ms:  HTML lengkap dengan konten diterima → user langsung lihat konten
  T=400ms:  JavaScript dimuat → halaman fully interactive

CSR — apa yang user lihat per tahap:
  T=0ms:    Request dikirim
  T=50ms:   HTML kosong diterima → blank page
  T=600ms:  JavaScript bundle dimuat → loading spinner
  T=1200ms: API call selesai → konten muncul
  T=1400ms: Halaman fully interactive

Perbedaan persepsi: SSR terasa "instan", CSR terasa "loading"

Core Web Vitals yang Terpengaruh #

Google menggunakan tiga metrik utama untuk mengukur pengalaman pengguna, dan ketiganya sangat dipengaruhi oleh pilihan rendering strategy.

LCP — Largest Contentful Paint
  Mengukur: kapan elemen terbesar di viewport selesai dirender
  Target: < 2.5 detik
  
  CSR biasanya buruk di LCP:
  → HTML awal kosong → tidak ada konten yang bisa dirender segera
  → LCP baru terjadi setelah JavaScript selesai + API response tiba
  → LCP CSR biasanya 2–5 detik vs SSR 0.5–1.5 detik
  
  Cara memperbaiki di CSR:
  → Preload critical API data di HTML (window.__INITIAL_DATA__)
  → Gunakan skeleton screen alih-alih blank screen
  → Pastikan above-the-fold content tidak butuh API call tambahan

FID / INP — First Input Delay / Interaction to Next Paint
  Mengukur: seberapa cepat halaman merespons interaksi user
  Target FID: < 100ms, Target INP: < 200ms
  
  CSR bisa baik atau buruk di sini:
  → Setelah JavaScript fully loaded, interaksi biasanya sangat cepat
  → Tapi selama main thread sibuk parsing bundle besar → input terlambat direspons
  
  Cara memperbaiki:
  → Code splitting agar tidak semua JS dimuat di awal
  → Hindari long task (> 50ms) di main thread
  → Defer non-critical JavaScript

CLS — Cumulative Layout Shift
  Mengukur: seberapa banyak elemen halaman bergeser tidak terduga
  Target: < 0.1
  
  CSR rentan terhadap CLS:
  → Content dimuat setelah layout awal → elemen bergeser saat data muncul
  → Skeleton screen tanpa ukuran yang tepat menyebabkan shift
  
  Cara memperbaiki:
  → Set dimensi eksplisit untuk semua elemen sebelum data dimuat
  → Gunakan skeleton screen dengan ukuran yang sesuai konten asli

Code Splitting — Fondasi Performa CSR #

Bundling semua JavaScript menjadi satu file adalah anti-pattern yang paling umum di CSR. Code splitting membagi bundle menjadi potongan-potongan kecil yang dimuat sesuai kebutuhan.

flowchart LR
    subgraph NoCSSplit["Tanpa Code Splitting"]
        BigBundle["app.bundle.js\n2.5 MB\nSemua kode sekaligus"]
        PageA1["Halaman A"] --> BigBundle
        PageB1["Halaman B"] --> BigBundle
        PageC1["Halaman C"] --> BigBundle
    end

    subgraph WithSplit["Dengan Code Splitting"]
        MainBundle["main.bundle.js\n150 KB\nCore app saja"]
        ChunkA["pageA.chunk.js\n80 KB"]
        ChunkB["pageB.chunk.js\n120 KB"]
        ChunkC["pageC.chunk.js\n95 KB"]
        PageA2["Halaman A"] --> MainBundle
        PageA2 --> ChunkA
        PageB2["Halaman B"] --> MainBundle
        PageB2 --> ChunkB
        PageC2["Halaman C"] --> MainBundle
        PageC2 --> ChunkC
    end

    style BigBundle fill:#E74C3C,color:#fff
    style MainBundle fill:#27AE60,color:#fff
// ANTI-PATTERN: Import semua komponen di awal
// (semua halaman di-link statis — semua kode dimuat bahkan jika user hanya
//  buka halaman Dashboard)
import (
    _ "example.com/app/pages/dashboard"
    _ "example.com/app/pages/reports"
    _ "example.com/app/pages/analytics"
    _ "example.com/app/pages/settings"
)

// BENAR: Halaman lazy-loaded — padanan React.lazy
// Registri fungsi loader: setiap chunk di-fetch saat pertama kali dipakai
var pageLoaders = map[string]func() *Element{
    "/dashboard": dashboard.Load,
    "/reports":   reports.Load,
    "/analytics": analytics.Load,
    "/settings":  settings.Load,
}

// Webpack/Vite otomatis buat chunk terpisah untuk setiap import dinamis
// Dashboard.chunk.js hanya dimuat saat user buka halaman Dashboard
func App() *Element {
    return renderRoutes(pageLoaders, renderPageSkeleton)
}

Panduan ukuran bundle:

  • Initial bundle (main chunk): targetkan di bawah 150–200 KB (gzipped)
  • Per-page chunk: idealnya di bawah 100 KB (gzipped)
  • Vendor chunk (React, dll): pisahkan agar bisa di-cache lebih lama

Lazy Loading Komponen dan Data #

Lazy loading bukan hanya untuk routes — komponen berat yang tidak terlihat di viewport awal juga bisa dimuat secara lazy.

// Lazy loading komponen berat yang tidak terlihat di awal
// (padanan React.lazy — setiap modul dimuat saat pertama kali dipakai)
var HeavyChart = lazyLoad(func() *Element { return importHeavyChart() })     // chart library biasanya besar
var RichTextEditor = lazyLoad(func() *Element { return importRichTextEditor() })
var VideoPlayer = lazyLoad(func() *Element { return importVideoPlayer() })

func ReportPage() *Element {
    showChart := false

    return renderFragment(
        renderReportSummary(),
        // Chart hanya dimuat saat user klik tombol
        renderButton("Show Chart", func() { showChart = true }),
        showChart && renderSuspense(renderChartSkeleton, HeavyChart(reportData)),
    )
}

// Intersection Observer untuk lazy load saat scroll
type LazySection struct {
    children  *Element
    isVisible bool
}

func (s *LazySection) Mount() {
    var observer *IntersectionObserver
    observer = newIntersectionObserver(func(entries []IntersectionEntry) {
        for _, entry := range entries {
            if entry.IsIntersecting {
                s.isVisible = true
                observer.Disconnect()
            }
        }
    }, ObserverOptions{})

    observer.Observe(s.ref)
    // cleanup saat unmount: observer.Disconnect()
}

func (s *LazySection) Render() *Element {
    if s.isVisible {
        return s.children
    }
    return renderSectionSkeleton()
}

Strategi Caching Data di CSR #

Salah satu kelemahan CSR adalah setiap navigasi memerlukan API call. Caching data di sisi client mengurangi latency dan mengurangi beban server.

Level caching yang tersedia di CSR:

1. In-memory cache (React Query / SWR)
   → Data tersimpan selama sesi browser
   → Hilang saat refresh halaman
   → Paling cepat, tidak ada I/O
   
   Gunakan untuk: data yang sering diakses dalam satu sesi
   
   // React Query — cache otomatis dengan stale time
   const { data } = useQuery({
     queryKey: ['user-profile'],
     queryFn: fetchUserProfile,
     staleTime: 5 * 60 * 1000,  // 5 menit sebelum di-refetch
     cacheTime: 30 * 60 * 1000, // 30 menit disimpan di memory
   })

2. localStorage / sessionStorage
   → Persisten antar refresh (localStorage)
   → Hanya sesi ini (sessionStorage)
   → Maksimal 5–10 MB
   
   Gunakan untuk: data yang tidak sering berubah (config, preferences)
   Jangan gunakan untuk: data sensitif atau autentikasi token

3. Service Worker Cache (untuk PWA)
   → Dapat menyimpan response API secara offline
   → Bekerja bahkan saat tidak ada koneksi
   → Lebih kompleks untuk di-implement dan di-invalidate
   
   Gunakan untuk: aplikasi yang butuh offline capability

4. HTTP Cache (Cache-Control header)
   → Browser cache response API secara otomatis
   → Harus dikonfigurasi dari sisi server
   
   // Response API dengan cache header:
   Cache-Control: public, max-age=300  // cache 5 menit
   ETag: "abc123"  // untuk conditional request

Menangani Loading State dan Error #

Pengalaman CSR sangat bergantung pada bagaimana loading state dan error ditangani. Loading state yang buruk membuat aplikasi terasa lambat meskipun sebenarnya tidak.

// ANTI-PATTERN: Satu loading state untuk seluruh halaman
func Dashboard() *Element {
    data, loading := useFetchAll()

    if loading {
        return renderDiv("Loading...") // seluruh halaman kosong
    }

    return renderDashboardContent(data)
}

// BENAR: Loading state granular per section
func Dashboard() *Element {
    return renderDiv("dashboard",
        // Stats cards — data kecil, muncul duluan
        renderStatsSection(),
        // Chart — data lebih besar, loading sendiri
        renderChartSection(),
        // Recent activity — bisa dimuat terakhir
        renderActivitySection(),
    )
}

func renderStatsSection() *Element {
    data, loading, err := useStats()

    if loading {
        return renderStatsSkeleton()
    }
    if err != nil {
        return renderStatsError(func() { /* retry */ })
    }
    return renderStats(data)
}
// Error Boundary untuk menangkap error yang tidak terduga
// (padanan recover di level atas di sekitar setiap subtree komponen)
type ErrorBoundary struct {
    hasError bool
    err      error
    children *Element
}

// Padanan getDerivedStateFromError
func (b *ErrorBoundary) OnError(err error) {
    b.hasError = true
    b.err = err
}

func (b *ErrorBoundary) render() *Element {
    if b.hasError {
        // Log ke monitoring service (Sentry, Datadog)
        logErrorToService(b.err, b.children)

        return renderDiv("error-fallback",
            renderH2("Terjadi kesalahan"),
            renderP("Coba refresh halaman atau hubungi support."),
            renderButton("Refresh Halaman", func() { window.Location.Reload() }),
        )
    }
    return b.children
}

// Gunakan di setiap section penting
renderErrorBoundary(renderChartSection())

SEO di CSR — Tantangan dan Solusi #

Mesin pencari seperti Google memang sudah bisa menjalankan JavaScript, tapi ada keterbatasan yang perlu dipahami.

Tantangan SEO di CSR:

1. Crawl budget
   Googlebot punya "budget" untuk crawl setiap site
   JavaScript rendering membutuhkan resource lebih
   → Halaman yang butuh JS untuk render mungkin tidak di-crawl semua

2. Indexing delay
   HTML awal yang kosong di-index duluan
   JavaScript dieksekusi asynchronously saat "second wave" crawling
   → Ada delay antara deployment dan konten ter-index

3. Social sharing
   Twitter, Facebook, LinkedIn membaca meta tag dari HTML awal
   HTML kosong = tidak ada preview saat di-share

Solusi:

1. Pre-rendering (paling sederhana)
   Build static HTML untuk setiap route saat deploy
   Tools: react-snap, prerender.io, Netlify Prerendering
   Cocok untuk: halaman yang kontennya tidak berubah sering

2. Hybrid rendering
   Render halaman yang butuh SEO di server (SSR)
   Biarkan halaman yang tidak butuh SEO tetap CSR
   Contoh: Next.js dengan getServerSideProps/getStaticProps per page
   Ini adalah pendekatan yang paling fleksibel

3. Dynamic Rendering
   Serve HTML yang sudah di-render ke bot crawler
   Serve JavaScript ke user biasa
   Tools: Rendertron (Googlebot-aware)
   Kurang direkomendasikan — Google menyebutnya sebagai "cloaking" jika berlebihan
Jika aplikasi yang kamu bangun adalah admin panel internal, dashboard, atau tool yang tidak perlu di-index mesin pencari — SEO bukan masalah dan CSR murni adalah pilihan yang sangat tepat. Kompleksitas hybrid rendering hanya layak jika ada kebutuhan SEO yang nyata.

Kapan Menggunakan CSR #

CSR adalah pilihan yang tepat jika:
  ✓ Aplikasi yang sangat interaktif dengan banyak state di client
     (admin panel, dashboard, kanban board, tools)
  ✓ Aplikasi yang membutuhkan real-time update
     (chat app, notification center, live data)
  ✓ Autentikasi diperlukan untuk mengakses semua halaman
     (tidak ada halaman publik yang perlu SEO)
  ✓ SPA dengan navigasi yang sangat sering
     (user berpindah halaman berkali-kali dalam satu sesi)
  ✓ Koneksi internet yang diasumsikan bagus
     (internal tools, enterprise apps)

CSR kurang tepat jika:
  ✗ Konten publik yang butuh SEO
     (blog, landing page, halaman produk e-commerce)
  ✗ First load performance sangat kritis
     (user baru yang belum punya cache, mobile dengan koneksi lambat)
  ✗ Konten yang sering di-share di media sosial
     (perlu meta tags yang benar di HTML awal)
  ✗ Pengguna dengan perangkat low-end
     (JavaScript execution mahal di perangkat lama)
  ✗ Core content yang harus tersedia meski JavaScript gagal
flowchart TD
    Q1{"Apakah konten perlu\ndi-index mesin pencari?"}
    Q2{"Apakah halaman sangat\ninteraktif dengan\nbanyak state client?"}
    Q3{"Apakah first load\nperforma sangat kritis?"}

    SSR["Pertimbangkan SSR\natau Hybrid (Next.js)"]
    CSR["CSR adalah pilihan\nyang tepat"]
    Hybrid["Hybrid:\nSSR untuk halaman publik\nCSR untuk app shell"]

    Q1 -->|"Ya"| SSR
    Q1 -->|"Tidak"| Q2
    Q2 -->|"Ya"| Q3
    Q2 -->|"Tidak"| SSR
    Q3 -->|"Ya"| Hybrid
    Q3 -->|"Tidak"| CSR

    style CSR fill:#27AE60,color:#fff
    style SSR fill:#E67E22,color:#fff
    style Hybrid fill:#8E44AD,color:#fff

Anti-Pattern CSR yang Harus Dihindari #

Bundle Monolitik Tanpa Splitting #

// ✗ Anti-pattern: satu bundle besar
// (ketiga modul di-link ke binary)
import (
    _ "example.com/app/adminpanel"      // 300KB
    _ "example.com/app/analytics"       // 250KB
    _ "example.com/app/reportgenerator" // 200KB
)
// Semua dimuat di awal meski user hanya buka halaman home

// ✓ Solusi: Route-based code splitting
// Setiap halaman menjadi chunk lazy-loaded sendiri
var AdminPanel = lazyLoad(func() *Element { return loadAdminPanel() })         // chunk 300KB
var AnalyticsDashboard = lazyLoad(func() *Element { return loadAnalytics() })  // chunk 250KB
var ReportGenerator = lazyLoad(func() *Element { return loadReports() })       // chunk 200KB

API Waterfall #

// ✗ Anti-pattern: sequential API calls (waterfall)
func loadData() {
    user, _ := fetchUser()                         // tunggu 300ms
    settings, _ := fetchSettings(user.ID)          // tunggu 300ms lagi
    dashboard, _ := fetchDashboard(settings.Theme) // tunggu 300ms lagi
    // Total: 900ms karena sequential
}

// ✓ Solusi: parallel API calls
func loadData() {
    // Fetch semua sekaligus jika tidak ada dependency
    var (
        wg        sync.WaitGroup
        user      any
        settings  any
        dashboard any
    )
    wg.Add(3)
    go func() { defer wg.Done(); user, _ = fetchUser() }()
    go func() { defer wg.Done(); settings, _ = fetchSettings() }()
    go func() { defer wg.Done(); dashboard, _ = fetchDashboard() }()
    wg.Wait()
    // Total: ~300ms (waktu request terlama)
}

Tidak Ada Loading State yang Bermakna #

// ✗ Anti-pattern: halaman kosong saat loading
if loading {
    return nil // user melihat blank page
}
if loading {
    return renderDiv("Loading...") // terlalu generik, tidak ada ukuran
}

// ✓ Solusi: skeleton screen yang menyerupai layout konten
if loading {
    return renderDiv("dashboard-skeleton",
        renderDiv("skeleton-header", Style{Height: 64}),
        renderDiv("skeleton-stats",
            renderDiv("skeleton-stat-card", Style{Height: 120}),
            renderDiv("skeleton-stat-card", Style{Height: 120}),
            renderDiv("skeleton-stat-card", Style{Height: 120}),
            renderDiv("skeleton-stat-card", Style{Height: 120}),
        ),
        renderDiv("skeleton-chart", Style{Height: 300}),
    )
}
// Skeleton mencegah layout shift saat data muncul

Checklist CSR #

PERFORMANCE:
  □ Initial bundle size < 200 KB gzipped
  □ Route-based code splitting diimplementasikan
  □ Komponen berat (chart, editor) dimuat secara lazy
  □ Vendor dependencies dipisahkan ke chunk tersendiri (lebih lama di cache)
  □ Tree shaking aktif (tidak import library yang tidak digunakan)

API DAN DATA:
  □ API calls yang tidak bergantung satu sama lain di-fetch secara parallel
  □ Data yang sering diakses di-cache dengan React Query atau SWR
  □ Stale time dikonfigurasi sesuai frekuensi perubahan data
  □ Error state di-handle di setiap data fetch

UI DAN UX:
  □ Skeleton screen ada untuk setiap section yang menunggu data
  □ Error boundary ada untuk menangkap JavaScript error yang tidak terduga
  □ Empty state ada untuk daftar yang kosong (bukan blank)
  □ Loading state granular per section, bukan seluruh halaman

SEO (jika ada konten publik):
  □ Pre-rendering atau SSR hybrid untuk halaman yang butuh SEO
  □ Meta tags penting tersedia di HTML awal
  □ Open Graph tags tersedia untuk social sharing

MONITORING:
  □ Core Web Vitals (LCP, INP, CLS) dipantau
  □ JavaScript error di-track ke monitoring service (Sentry)
  □ Bundle size dipantau dan ada alert jika melebihi threshold
  □ API response time dipantau dari perspektif client

Ringkasan #

  • CSR memindahkan rendering ke browser — server hanya mengirim HTML kosong dan JavaScript bundle, lalu browser yang membangun UI. Ini bagus untuk interaktivitas, tapi ada biaya performa awal yang nyata.
  • LCP biasanya buruk di CSR murni — karena konten baru muncul setelah JavaScript selesai diunduh dan API response tiba. Target LCP < 2.5 detik butuh effort khusus di CSR.
  • Code splitting adalah non-negotiable — bundle monolitik yang dimuat sekaligus adalah sumber masalah performa terbesar di CSR. Route-based splitting minimal harus ada.
  • Parallel API calls, bukan sequentialPromise.all() untuk request yang tidak saling bergantung. Waterfall API calls bisa dengan mudah melipatgandakan waktu loading.
  • Skeleton screen lebih baik dari loading spinner — skeleton mencegah CLS (layout shift) saat data muncul dan membuat aplikasi terasa lebih responsif karena menunjukkan struktur yang akan muncul.
  • Error boundary wajib ada — JavaScript error yang tidak tertangkap akan membuat seluruh halaman blank. Error boundary memberikan fallback UI yang bermakna.
  • CSR bukan pilihan untuk halaman publik yang butuh SEO — pertimbangkan SSR atau pre-rendering untuk halaman yang perlu di-index mesin pencari.
  • CSR sangat cocok untuk admin panel dan internal tools — di mana SEO tidak relevan, koneksi internet bagus, dan interaktivitas tinggi adalah requirement utama.
  • Caching data di client mengurangi latency yang dirasakan — React Query dan SWR dengan stale time yang tepat membuat navigasi antar halaman terasa instan karena data sudah tersedia.
  • Monitoring Core Web Vitals di production — performa yang terukur adalah satu-satunya cara mengetahui apakah optimasi yang dilakukan benar-benar berdampak di perangkat pengguna nyata.

← Sebelumnya: Idempotency   Berikutnya: SSR →

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