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 normalDibandingkan 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:#fffAnti-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 sequential —
Promise.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.