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
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:#fffAnti-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 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.