CSR #
Client-Side Rendering is the paradigm where the browser is responsible for building the page’s appearance — the server only sends empty HTML, JavaScript bundles, and data via APIs. In the era of React, Vue, and Angular, CSR became the default many teams adopt without really considering its trade-offs. There are cases where CSR is the best choice: highly interactive apps, internal dashboards, tools needing real-time updates. But there are also cases where CSR is the wrong choice: landing pages needing SEO, e-commerce product pages, content needing search engine indexing. This article covers how CSR works in depth, the performance metrics affected, and best practices for running CSR optimally.
How CSR Works #
To understand CSR’s trade-offs, you first need to understand what happens from the moment a user opens a URL until the page is visible and usable.
sequenceDiagram
participant B as Browser
participant S as Server / CDN
participant API as API Server
B->>S: GET /dashboard
S-->>B: Empty HTML + <script src="app.js">
Note over B: The page is still white (blank)
B->>S: GET /app.js (bundle ~500KB)
S-->>B: JavaScript bundle
Note over B: Parse and execute JavaScript (~200-500ms)
B->>B: React/Vue bootstrap, render loading state
Note over B: The user sees a loading spinner
B->>API: GET /api/user, GET /api/dashboard-data
API-->>B: JSON response
B->>B: Update state, re-render the UI with data
Note over B: The page is fully interactive
Note over B,API: Total time: 1.5-4 seconds on a normal connectionCompared with Server-Side Rendering:
SSR — what users see at each stage:
T=0ms: Request sent
T=200ms: Complete HTML with content received → users immediately see content
T=400ms: JavaScript loaded → page fully interactive
CSR — what users see at each stage:
T=0ms: Request sent
T=50ms: Empty HTML received → blank page
T=600ms: JavaScript bundle loaded → loading spinner
T=1200ms: API calls finished → content appears
T=1400ms: Page fully interactive
Perception difference: SSR feels "instant", CSR feels "loading"
Core Web Vitals Affected #
Google uses three main metrics to measure user experience, and all three are strongly affected by the rendering strategy choice.
LCP — Largest Contentful Paint
Measures: when the largest viewport element finishes rendering
Target: < 2.5 seconds
CSR is usually bad at LCP:
→ Empty initial HTML → no content that can render immediately
→ LCP only happens after JavaScript finishes + API responses arrive
→ CSR LCP is usually 2-5 seconds vs SSR's 0.5-1.5 seconds
How to improve in CSR:
→ Preload critical API data in the HTML (window.__INITIAL_DATA__)
→ Use skeleton screens instead of blank screens
→ Make sure above-the-fold content doesn't need additional API calls
FID / INP — First Input Delay / Interaction to Next Paint
Measures: how fast the page responds to user interactions
Target FID: < 100ms, Target INP: < 200ms
CSR can be good or bad here:
→ After JavaScript is fully loaded, interactions are usually very fast
→ But while the main thread is busy parsing big bundles → input responds late
How to improve:
→ Code splitting so not all JS loads upfront
→ Avoid long tasks (> 50ms) on the main thread
→ Defer non-critical JavaScript
CLS — Cumulative Layout Shift
Measures: how much page elements shift unexpectedly
Target: < 0.1
CSR is prone to CLS:
→ Content loads after the initial layout → elements shift when data appears
→ Skeleton screens without proper sizes cause shifts
How to improve:
→ Set explicit dimensions for all elements before data loads
→ Use skeleton screens sized to match the real content
Code Splitting — The Foundation of CSR Performance #
Bundling all JavaScript into one file is the most common CSR anti-pattern. Code splitting divides the bundle into small pieces loaded on demand.
flowchart LR
subgraph NoCSSplit["Without Code Splitting"]
BigBundle["app.bundle.js\n2.5 MB\nAll code at once"]
PageA1["Page A"] --> BigBundle
PageB1["Page B"] --> BigBundle
PageC1["Page C"] --> BigBundle
end
subgraph WithSplit["With Code Splitting"]
MainBundle["main.bundle.js\n150 KB\nCore app only"]
ChunkA["pageA.chunk.js\n80 KB"]
ChunkB["pageB.chunk.js\n120 KB"]
ChunkC["pageC.chunk.js\n95 KB"]
PageA2["Page A"] --> MainBundle
PageA2 --> ChunkA
PageB2["Page B"] --> MainBundle
PageB2 --> ChunkB
PageC2["Page C"] --> MainBundle
PageC2 --> ChunkC
end
style BigBundle fill:#E74C3C,color:#fff
style MainBundle fill:#27AE60,color:#fff// ANTI-PATTERN: Importing all components upfront
// (all pages are linked statically — all code loads even if the user only
// opens the Dashboard page)
import (
_ "example.com/app/pages/dashboard"
_ "example.com/app/pages/reports"
_ "example.com/app/pages/analytics"
_ "example.com/app/pages/settings"
)
// CORRECT: Lazy-loaded pages — the equivalent of React.lazy
// A registry of loader functions: each chunk is fetched on first use
var pageLoaders = map[string]func() *Element{
"/dashboard": dashboard.Load,
"/reports": reports.Load,
"/analytics": analytics.Load,
"/settings": settings.Load,
}
// The bundler splits each page into its own chunk;
// the Dashboard chunk only loads when the user opens the Dashboard page
func App() *Element {
return renderRoutes(pageLoaders, renderPageSkeleton)
}
Bundle size guidance:
- Initial bundle (main chunk): target below 150-200 KB (gzipped)
- Per-page chunks: ideally below 100 KB (gzipped)
- Vendor chunks (React, etc.): separate so they can be cached longer
Lazy Loading Components and Data #
Lazy loading isn’t only for routes — heavy components not visible in the initial viewport can also be loaded lazily.
// Lazy loading heavy components not visible initially
// (the equivalent of React.lazy — each module loads on first use)
var HeavyChart = lazyLoad(func() *Element { return importHeavyChart() }) // chart libs are usually big
var RichTextEditor = lazyLoad(func() *Element { return importRichTextEditor() })
var VideoPlayer = lazyLoad(func() *Element { return importVideoPlayer() })
func ReportPage() *Element {
showChart := false
return renderFragment(
renderReportSummary(),
// The chart only loads when the user clicks the button
renderButton("Show Chart", func() { showChart = true }),
showChart && renderSuspense(renderChartSkeleton, HeavyChart(reportData)),
)
}
// Intersection Observer for scroll-triggered lazy loading
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 on unmount: observer.Disconnect()
}
func (s *LazySection) Render() *Element {
if s.isVisible {
return s.children
}
return renderSectionSkeleton()
}
Data Caching Strategies in CSR #
One of CSR’s weaknesses is that every navigation requires an API call. Client-side data caching reduces latency and server load.
Caching levels available in CSR:
1. In-memory cache (React Query / SWR)
→ Data stored for the browser session
→ Lost on page refresh
→ Fastest, no I/O
Use for: data frequently accessed within a session
// React Query — automatic caching with stale time
const { data } = useQuery({
queryKey: ['user-profile'],
queryFn: fetchUserProfile,
staleTime: 5 * 60 * 1000, // 5 minutes before refetching
cacheTime: 30 * 60 * 1000, // 30 minutes kept in memory
})
2. localStorage / sessionStorage
→ Persistent across refreshes (localStorage)
→ Session only (sessionStorage)
→ Maximum 5-10 MB
Use for: data that doesn't change often (config, preferences)
Don't use for: sensitive data or authentication tokens
3. Service Worker Cache (for PWAs)
→ Can store API responses for offline use
→ Works even without a connection
→ More complex to implement and invalidate
Use for: apps needing offline capability
4. HTTP Cache (Cache-Control headers)
→ Browsers cache API responses automatically
→ Must be configured server-side
// API response with cache headers:
Cache-Control: public, max-age=300 // cache 5 minutes
ETag: "abc123" // for conditional requests
Handling Loading States and Errors #
The CSR experience heavily depends on how loading states and errors are handled. Poor loading states make apps feel slow even when they aren’t.
// ANTI-PATTERN: One loading state for the whole page
func Dashboard() *Element {
data, loading := useFetchAll()
if loading {
return renderDiv("Loading...") // the entire page is empty
}
return renderDashboardContent(data)
}
// CORRECT: Granular loading states per section
func Dashboard() *Element {
return renderDiv("dashboard",
// Stats cards — small data, appears first
renderStatsSection(),
// Chart — bigger data, loads independently
renderChartSection(),
// Recent activity — can load last
renderActivitySection(),
)
}
func renderStatsSection() *Element {
data, loading, err := useStats()
if loading {
return renderStatsSkeleton()
}
if err != nil {
return renderStatsError(func() { /* retry */ })
}
return renderStats(data)
}
// Error Boundary to catch unexpected errors
// (the equivalent of a top-level recover around each component subtree)
type ErrorBoundary struct {
hasError bool
err error
children *Element
}
// getDerivedStateFromError equivalent
func (b *ErrorBoundary) OnError(err error) {
b.hasError = true
b.err = err
}
func (b *ErrorBoundary) render() *Element {
if b.hasError {
// Log to a monitoring service (Sentry, Datadog)
logErrorToService(b.err, b.children)
return renderDiv("error-fallback",
renderH2("Something went wrong"),
renderP("Try refreshing the page or contact support."),
renderButton("Refresh Page", func() { window.Location.Reload() }),
)
}
return b.children
}
// Use it around every important section
renderErrorBoundary(renderChartSection())
SEO in CSR — Challenges and Solutions #
Search engines like Google can execute JavaScript now, but there are limitations to understand.
SEO challenges in CSR:
1. Crawl budget
Googlebot has a "budget" for crawling each site
JavaScript rendering requires more resources
→ Pages needing JS to render may not all be crawled
2. Indexing delay
The empty initial HTML gets indexed first
JavaScript executes asynchronously during "second wave" crawling
→ There's a delay between deployment and content being indexed
3. Social sharing
Twitter, Facebook, LinkedIn read meta tags from the initial HTML
Empty HTML = no preview when shared
Solutions:
1. Pre-rendering (simplest)
Build static HTML for every route at deploy time
Tools: react-snap, prerender.io, Netlify Prerendering
Good for: pages whose content doesn't change often
2. Hybrid rendering
Render SEO-needing pages on the server (SSR)
Let non-SEO pages stay CSR
Example: Next.js with getServerSideProps/getStaticProps per page
This is the most flexible approach
3. Dynamic Rendering
Serve pre-rendered HTML to crawler bots
Serve JavaScript to regular users
Tools: Rendertron (Googlebot-aware)
Less recommended — Google calls it "cloaking" if overdone
If the app you’re building is an internal admin panel, dashboard, or tool that doesn’t need search engine indexing — SEO isn’t a problem and pure CSR is a very appropriate choice. Hybrid rendering complexity is only worthwhile when there’s a real SEO need.
When to Use CSR #
CSR is the right choice if:
✓ Highly interactive apps with lots of client-side state
(admin panels, dashboards, kanban boards, tools)
✓ Apps needing real-time updates
(chat apps, notification centers, live data)
✓ Authentication required to access all pages
(no public pages needing SEO)
✓ SPAs with very frequent navigation
(users switching pages many times per session)
✓ Good internet connections assumed
(internal tools, enterprise apps)
CSR is less appropriate if:
✗ Public content needing SEO
(blogs, landing pages, e-commerce product pages)
✗ First-load performance is critical
(new users without caches, mobile users on slow connections)
✗ Content frequently shared on social media
(needs correct meta tags in the initial HTML)
✗ Users on low-end devices
(JavaScript execution is expensive on old devices)
✗ Core content must be available even if JavaScript fails
flowchart TD
Q1{"Does the content need\nsearch engine indexing?"}
Q2{"Is the page highly\ninteractive with lots\nof client state?"}
Q3{"Is first-load\nperformance critical?"}
SSR["Consider SSR\nor Hybrid (Next.js)"]
CSR["CSR is the\nright choice"]
Hybrid["Hybrid:\nSSR for public pages\nCSR for the app shell"]
Q1 -->|"Yes"| SSR
Q1 -->|"No"| Q2
Q2 -->|"Yes"| Q3
Q2 -->|"No"| SSR
Q3 -->|"Yes"| Hybrid
Q3 -->|"No"| CSR
style CSR fill:#27AE60,color:#fff
style SSR fill:#E67E22,color:#fff
style Hybrid fill:#8E44AD,color:#fffCSR Anti-Patterns to Avoid #
Monolithic Bundles Without Splitting #
// ✗ Anti-pattern: one big bundle
// (all three modules are linked into the binary)
import (
_ "example.com/app/adminpanel" // 300KB
_ "example.com/app/analytics" // 250KB
_ "example.com/app/reportgenerator" // 200KB
)
// Everything loads upfront even if the user only opens the home page
// ✓ Solution: Route-based code splitting
// Each page becomes its own lazy-loaded chunk
var AdminPanel = lazyLoad(func() *Element { return loadAdminPanel() }) // 300KB chunk
var AnalyticsDashboard = lazyLoad(func() *Element { return loadAnalytics() }) // 250KB chunk
var ReportGenerator = lazyLoad(func() *Element { return loadReports() }) // 200KB chunk
API Waterfalls #
// ✗ Anti-pattern: sequential API calls (waterfall)
func loadData() {
user, _ := fetchUser() // wait 300ms
settings, _ := fetchSettings(user.ID) // wait another 300ms
dashboard, _ := fetchDashboard(settings.Theme) // wait another 300ms
// Total: 900ms because sequential
}
// ✓ Solution: parallel API calls
func loadData() {
// Fetch everything at once if there are no dependencies
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 (the slowest request's time)
}
No Meaningful Loading States #
// ✗ Anti-pattern: blank page while loading
if loading {
return nil // users see a blank page
}
if loading {
return renderDiv("Loading...") // too generic, no dimensions
}
// ✓ Solution: skeleton screens resembling the content layout
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}),
)
}
// Skeletons prevent layout shifts when data appears
CSR Checklist #
PERFORMANCE:
□ Initial bundle size < 200 KB gzipped
□ Route-based code splitting implemented
□ Heavy components (charts, editors) loaded lazily
□ Vendor dependencies split into their own chunk (cached longer)
□ Tree shaking active (no unused library imports)
API AND DATA:
□ Independent API calls fetched in parallel
□ Frequently accessed data cached with React Query or SWR
□ Stale times configured to match data change frequency
□ Error states handled on every data fetch
UI AND UX:
□ Skeleton screens exist for every section waiting on data
□ Error boundaries catch unexpected JavaScript errors
□ Empty states exist for empty lists (not blank)
□ Granular loading states per section, not whole pages
SEO (if there's public content):
□ Pre-rendering or hybrid SSR for SEO-needing pages
□ Important meta tags available in the initial HTML
□ Open Graph tags available for social sharing
MONITORING:
□ Core Web Vitals (LCP, INP, CLS) monitored
□ JavaScript errors tracked to a monitoring service (Sentry)
□ Bundle sizes monitored with alerts if thresholds exceeded
□ API response times monitored from the client's perspective
Summary #
- CSR moves rendering to the browser — the server only sends empty HTML and JavaScript bundles, then the browser builds the UI. Great for interactivity, but there’s a real initial performance cost.
- LCP is usually bad in pure CSR — because content only appears after JavaScript finishes downloading and API responses arrive. The < 2.5 second LCP target needs special effort in CSR.
- Code splitting is non-negotiable — monolithic bundles loaded at once are the biggest source of CSR performance problems. Route-based splitting is the minimum.
- Parallel API calls, not sequential —
Promise.all() for independent requests. API waterfalls can easily multiply loading times. - Skeleton screens are better than loading spinners — skeletons prevent CLS (layout shifts) when data appears and make apps feel more responsive by showing the structure coming.
- Error boundaries are mandatory — uncaught JavaScript errors blank the entire page. Error boundaries provide meaningful fallback UIs.
- CSR isn’t the choice for public SEO-needing pages — consider SSR or pre-rendering for pages that need search engine indexing.
- CSR is great for admin panels and internal tools — where SEO is irrelevant, internet connections are good, and high interactivity is the main requirement.
- Client-side data caching reduces perceived latency — React Query and SWR with proper stale times make cross-page navigation feel instant because data is already available.
- Monitor Core Web Vitals in production — measured performance is the only way to know whether optimizations actually impact real user devices.
#
- CSR moves rendering to the browser — the server only sends empty HTML and JavaScript bundles, then the browser builds the UI. Great for interactivity, but there’s a real initial performance cost.
- LCP is usually bad in pure CSR — because content only appears after JavaScript finishes downloading and API responses arrive. The < 2.5 second LCP target needs special effort in CSR.
- Code splitting is non-negotiable — monolithic bundles loaded at once are the biggest source of CSR performance problems. Route-based splitting is the minimum.
- Parallel API calls, not sequential —
Promise.all()for independent requests. API waterfalls can easily multiply loading times. - Skeleton screens are better than loading spinners — skeletons prevent CLS (layout shifts) when data appears and make apps feel more responsive by showing the structure coming.
- Error boundaries are mandatory — uncaught JavaScript errors blank the entire page. Error boundaries provide meaningful fallback UIs.
- CSR isn’t the choice for public SEO-needing pages — consider SSR or pre-rendering for pages that need search engine indexing.
- CSR is great for admin panels and internal tools — where SEO is irrelevant, internet connections are good, and high interactivity is the main requirement.
- Client-side data caching reduces perceived latency — React Query and SWR with proper stale times make cross-page navigation feel instant because data is already available.
- Monitor Core Web Vitals in production — measured performance is the only way to know whether optimizations actually impact real user devices.