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
import Dashboard from './pages/Dashboard'
import Reports from './pages/Reports'
import Analytics from './pages/Analytics'
import Settings from './pages/Settings'
// Semua kode dimuat bahkan jika user hanya buka halaman Dashboard

// BENAR: Dynamic import dengan React.lazy
import { lazy, Suspense } from 'react'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const Reports = lazy(() => import('./pages/Reports'))
const Analytics = lazy(() => import('./pages/Analytics'))
const Settings = lazy(() => import('./pages/Settings'))

// Webpack/Vite otomatis buat chunk terpisah untuk setiap import dinamis
// Dashboard.chunk.js hanya dimuat saat user buka halaman Dashboard

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/reports" element={<Reports />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  )
}

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
import { lazy, Suspense } from 'react'

// Chart library biasanya besar (Recharts ~300KB, Chart.js ~200KB)
const HeavyChart = lazy(() => import('./components/HeavyChart'))
const RichTextEditor = lazy(() => import('./components/RichTextEditor'))
const VideoPlayer = lazy(() => import('./components/VideoPlayer'))

function ReportPage() {
  const [showChart, setShowChart] = useState(false)

  return (
    <div>
      <ReportSummary />
      
      <button onClick={() => setShowChart(true)}>
        Tampilkan Grafik
      </button>
      
      {showChart && (
        // Chart hanya dimuat saat user klik tombol
        <Suspense fallback={<ChartSkeleton />}>
          <HeavyChart data={reportData} />
        </Suspense>
      )}
    </div>
  )
}

// Intersection Observer untuk lazy load saat scroll
function LazySection({ children }) {
  const [isVisible, setIsVisible] = useState(false)
  const ref = useRef()

  useEffect(() => {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsVisible(true)
        observer.disconnect()
      }
    })
    observer.observe(ref.current)
    return () => observer.disconnect()
  }, [])

  return (
    <div ref={ref}>
      {isVisible ? children : <SectionSkeleton />}
    </div>
  )
}

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
function Dashboard() {
  const { data, loading } = useFetchAll()
  
  if (loading) return <div>Loading...</div>  // seluruh halaman kosong
  
  return <DashboardContent data={data} />
}

// BENAR: Loading state granular per section
function Dashboard() {
  return (
    <div className="dashboard">
      {/* Stats cards — data kecil, muncul duluan */}
      <StatsSection />
      
      {/* Chart — data lebih besar, loading sendiri */}
      <ChartSection />
      
      {/* Recent activity — bisa dimuat terakhir */}
      <ActivitySection />
    </div>
  )
}

function StatsSection() {
  const { data, loading, error } = useStats()
  
  if (loading) return <StatsSkeleton />
  if (error) return <StatsError onRetry={retry} />
  return <Stats data={data} />
}
// Error Boundary untuk menangkap error JavaScript yang tidak terduga
class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null }

  static getDerivedStateFromError(error) {
    return { hasError: true, error }
  }

  componentDidCatch(error, errorInfo) {
    // Log ke monitoring service (Sentry, Datadog)
    logErrorToService(error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>Terjadi kesalahan</h2>
          <p>Coba refresh halaman atau hubungi support.</p>
          <button onClick={() => window.location.reload()}>
            Refresh Halaman
          </button>
        </div>
      )
    }
    return this.props.children
  }
}

// Gunakan di setiap section penting
<ErrorBoundary>
  <ChartSection />
</ErrorBoundary>

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
import AdminPanel from './AdminPanel'      // 300KB
import AnalyticsDashboard from './Analytics'  // 250KB
import ReportGenerator from './Reports'    // 200KB
// Semua dimuat di awal meski user hanya buka halaman home

// ✓ Solusi: Route-based code splitting
const AdminPanel = lazy(() => import('./AdminPanel'))
const AnalyticsDashboard = lazy(() => import('./Analytics'))
const ReportGenerator = lazy(() => import('./Reports'))

API Waterfall #

// ✗ Anti-pattern: sequential API calls (waterfall)
useEffect(() => {
  async function loadData() {
    const user = await fetchUser()        // tunggu 300ms
    const settings = await fetchSettings(user.id)  // tunggu 300ms lagi
    const dashboard = await fetchDashboard(settings.theme)  // tunggu 300ms lagi
    // Total: 900ms karena sequential
  }
  loadData()
}, [])

// ✓ Solusi: parallel API calls
useEffect(() => {
  async function loadData() {
    // Fetch semua sekaligus jika tidak ada dependency
    const [user, settings, dashboard] = await Promise.all([
      fetchUser(),
      fetchSettings(),
      fetchDashboard(),
    ])
    // Total: ~300ms (waktu request terlama)
  }
  loadData()
}, [])

Tidak Ada Loading State yang Bermakna #

// ✗ Anti-pattern: halaman kosong saat loading
if (loading) return null  // user melihat blank page
if (loading) return <div>Loading...</div>  // terlalu generik, tidak ada ukuran

// ✓ Solusi: skeleton screen yang menyerupai layout konten
if (loading) return (
  <div className="dashboard-skeleton">
    <div className="skeleton-header" style={{ height: 64 }} />
    <div className="skeleton-stats">
      {[1,2,3,4].map(i => (
        <div key={i} className="skeleton-stat-card" style={{ height: 120 }} />
      ))}
    </div>
    <div className="skeleton-chart" style={{ height: 300 }} />
  </div>
)
// 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