Overview
Client-Side Rendering (CSR) represents a web application architecture where the browser receives a minimal HTML document and JavaScript bundles that execute to generate the user interface dynamically. The server's role shifts from rendering complete HTML pages to serving as an API endpoint that provides data in JSON or other structured formats.
In a CSR application, the initial page load delivers a basic HTML shell containing script tags that reference JavaScript bundles. These bundles contain the application logic, component definitions, and rendering instructions. Once the JavaScript executes, it manipulates the Document Object Model (DOM) to create the visible interface, handles user interactions, fetches data from backend APIs, and updates the display in response to state changes.
This architecture contrasts with traditional server-side rendering where the server generates complete HTML for each request. CSR emerged as JavaScript engines improved and single-page application (SPA) frameworks gained adoption. The approach enables rich, application-like experiences in the browser, with instant navigation, optimistic updates, and complex client-side state management.
The Ruby ecosystem interacts with CSR through backend APIs built with Rails, Sinatra, Grape, or similar frameworks. These services handle authentication, business logic, and data persistence while the client-side JavaScript manages presentation and user interaction. Ruby applications serve the initial HTML shell and provide RESTful or GraphQL endpoints that JavaScript consumes.
<!-- Minimal HTML shell delivered by server -->
<!DOCTYPE html>
<html>
<head>
<title>CSR Application</title>
</head>
<body>
<div id="app"></div>
<script src="/bundles/application.js"></script>
</body>
</html>
// JavaScript takes over and renders the interface
import React from 'react';
import ReactDOM from 'react-dom';
function App() {
const [data, setData] = React.useState([]);
React.useEffect(() => {
fetch('/api/items')
.then(response => response.json())
.then(items => setData(items));
}, []);
return (
<div>
{data.map(item => <div key={item.id}>{item.name}</div>)}
</div>
);
}
ReactDOM.render(<App />, document.getElementById('app'));
Key Principles
Client-side rendering operates on several fundamental principles that distinguish it from server-rendered architectures.
JavaScript-Driven Rendering: The browser executes JavaScript code that programmatically creates and modifies DOM elements. Instead of receiving complete HTML from the server, the application constructs the interface through DOM manipulation APIs or virtual DOM abstractions. This grants fine-grained control over what renders and when.
API-Centric Backend: The server exposes data through HTTP APIs rather than generating HTML views. Controllers return JSON or XML instead of rendering templates. This separation allows the same backend to serve multiple client types (web, mobile, desktop) and enables independent development and deployment of frontend and backend code.
Single Page Application Pattern: CSR applications typically implement the SPA pattern where a single HTML page loads once and subsequent navigation occurs without full page reloads. JavaScript intercepts link clicks and browser history events, fetching only the necessary data and updating the displayed content. The URL changes through the History API while the page itself persists.
Client-Side Routing: Navigation logic executes in the browser rather than triggering server requests for new pages. A client-side router matches URL patterns to component definitions and renders the appropriate interface. Route changes occur instantly without network latency, though data fetching may still require server communication.
State Management in Browser: Application state resides in JavaScript memory rather than being recreated on each server request. Components maintain local state, and applications often employ centralized state management patterns. This enables optimistic updates, offline capabilities, and complex interactions without server round-trips.
Hydration Not Required: Unlike server-rendered applications that send HTML and then "hydrate" it with JavaScript interactivity, CSR applications start with an empty or minimal DOM and build everything client-side. This eliminates hydration mismatches but shifts all rendering work to the browser.
Progressive Enhancement Inverted: Traditional web development progressively enhances server-rendered HTML with JavaScript. CSR inverts this relationship—JavaScript becomes the foundation rather than the enhancement. The application requires JavaScript to function at all.
The data flow in CSR follows a unidirectional pattern: user actions trigger state updates, state changes cause re-renders, and the DOM updates to reflect the new state. This cycle happens entirely in the browser until data persistence requires API calls.
// State-driven rendering cycle
class Counter {
constructor() {
this.state = { count: 0 };
this.render();
}
increment() {
this.state.count++;
this.render();
}
render() {
document.getElementById('counter').textContent = this.state.count;
}
}
// User interaction → state update → re-render
const counter = new Counter();
document.getElementById('button').addEventListener('click', () => {
counter.increment();
});
Component-based architecture forms the structural foundation of CSR applications. The interface decomposes into reusable components with defined inputs (props) and internal state. Components compose hierarchically, with parent components passing data to children and children notifying parents of events through callbacks. This composition model scales from simple buttons to complex dashboards.
Design Considerations
Choosing client-side rendering involves analyzing multiple architectural trade-offs that impact performance, maintainability, user experience, and infrastructure.
Initial Load Performance: CSR applications ship more JavaScript to the browser compared to server-rendered alternatives. The browser must download, parse, and execute JavaScript before displaying anything interactive. For users on slow networks or underpowered devices, this creates a longer "time to interactive" where the page appears blank or shows a loading spinner. Server-side rendering delivers visible content faster because the browser can display HTML before JavaScript finishes loading.
Applications with marketing-focused landing pages where first impressions matter often fare poorly with pure CSR. E-commerce product pages that need instant visibility for conversion rate optimization face similar challenges. Internal business applications used by employees on high-speed corporate networks tolerate longer initial loads more easily.
SEO and Social Sharing: Search engine crawlers historically struggled with JavaScript-heavy sites, though modern crawlers execute JavaScript more reliably. However, crawler JavaScript execution remains less consistent than HTML parsing. Social media platforms that generate preview cards by scraping URLs typically don't execute JavaScript, resulting in broken or generic previews for CSR applications.
Applications requiring strong organic search presence—blogs, documentation sites, marketing pages—benefit from server-side rendering or static generation. Internal tools, authenticated dashboards, and applications behind login walls face fewer SEO concerns since search engines can't index authenticated content regardless of rendering approach.
Development Velocity: CSR separates frontend and backend development cleanly. Frontend engineers work with APIs and mock data without running the full backend stack. Backend engineers expose endpoints and test them independently. Teams can deploy frontend and backend separately, iterate on UI without backend changes, and develop features in parallel.
The API contract becomes the integration point. Changes to API responses require coordination, but implementation details remain isolated. This separation suits distributed teams, microservices architectures, and organizations scaling frontend and backend teams independently.
Infrastructure Costs: CSR shifts computational work from servers to client devices. Servers primarily handle API requests that query databases and return JSON—less CPU-intensive than rendering HTML templates. This reduces server costs for high-traffic applications, as the server load per user decreases. However, CDN costs may increase from serving larger JavaScript bundles.
Applications expecting millions of users or sudden traffic spikes benefit from CSR's reduced server load. The static assets (HTML, JavaScript, CSS) serve from CDNs that scale automatically and cost less per gigabyte than server processing time.
Offline Capabilities: CSR applications with Service Workers can cache application code and data locally, enabling offline operation. The application code already runs client-side, so adding offline support involves caching assets and implementing local data storage. Server-rendered applications require more complex offline strategies since the server generates the HTML.
Progressive Web Applications (PWAs) that function offline leverage CSR naturally. Mobile-first applications in regions with unreliable connectivity gain resilience through client-side rendering and local data storage.
Code Duplication: Pure CSR avoids rendering code duplication between server and client. The server focuses on data operations while the client handles presentation. However, validation logic often requires duplication—the client validates for immediate feedback, and the server validates for security. Some organizations adopt isomorphic rendering (running the same JavaScript server and client) to share code, though this introduces complexity.
API Design Impact: CSR applications require thoughtfully designed APIs. The frontend needs granular control over data fetching to avoid over-fetching or under-fetching. GraphQL emerged partly to address this, allowing clients to specify exact data requirements. RESTful APIs require careful endpoint design to provide necessary data without excessive requests.
The backend must handle CORS properly, implement proper authentication/authorization, and design for frontend needs rather than server-side template requirements. API versioning becomes critical since frontend and backend deploy independently.
Navigation Experience: Client-side navigation feels instant—clicking a link immediately displays the new view while data loads asynchronously. Server-side navigation introduces noticeable latency from the round-trip. Applications with frequent navigation between views benefit from CSR's instant transitions. However, browser features like back button prediction and link prefetching work better with server-side navigation.
Ruby Implementation
Ruby web frameworks serve as API backends for client-side rendered applications, handling authentication, business logic, and data persistence while the JavaScript frontend manages presentation.
Rails API Mode: Rails provides an API-only mode that omits view-rendering machinery and middleware specific to browser applications. This configuration optimizes Rails for serving JSON responses to JavaScript frontends.
# Generate API-only Rails application
# rails new my_app --api
# app/controllers/api/v1/posts_controller.rb
module Api
module V1
class PostsController < ApplicationController
def index
posts = Post.order(created_at: :desc).limit(20)
render json: posts,
include: [:author, :comments],
methods: [:excerpt]
end
def show
post = Post.find(params[:id])
render json: PostSerializer.new(post).serializable_hash
end
def create
post = current_user.posts.build(post_params)
if post.save
render json: post, status: :created, location: api_v1_post_url(post)
else
render json: { errors: post.errors }, status: :unprocessable_entity
end
end
private
def post_params
params.require(:post).permit(:title, :body, :published)
end
end
end
end
Rails API applications configure middleware for CORS, handle JSON request parsing, and set appropriate response formats. The API namespace typically includes version identifiers (v1, v2) to support multiple API versions simultaneously.
Serialization Control: Ruby applications need structured JSON serialization that controls which attributes expose, includes associations, and formats data for frontend consumption. The ActiveModel Serializers or FastJsonapi gems provide serialization abstractions.
# app/serializers/post_serializer.rb
class PostSerializer
include FastJsonapi::ObjectSerializer
attributes :id, :title, :body, :published_at, :slug
attribute :excerpt do |post|
post.body.truncate(200)
end
attribute :read_time do |post|
(post.body.split.size / 200.0).ceil
end
belongs_to :author, serializer: UserSerializer
has_many :comments, serializer: CommentSerializer
# Conditional attributes based on context
attribute :draft_notes, if: Proc.new { |post, params|
params[:current_user]&.admin? || params[:current_user]&.id == post.author_id
}
end
# Controller usage
render json: PostSerializer.new(
posts,
params: { current_user: current_user },
include: [:author, :comments]
).serializable_hash
Authentication for APIs: JWT (JSON Web Tokens) provide stateless authentication for CSR applications. The Ruby backend generates and verifies tokens without maintaining server-side session state.
# app/controllers/api/v1/authentication_controller.rb
class Api::V1::AuthenticationController < ApplicationController
skip_before_action :authenticate_request, only: [:login]
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: {
token: token,
user: UserSerializer.new(user).serializable_hash,
expires_at: 24.hours.from_now
}
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
def refresh
user = current_user
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token, expires_at: 24.hours.from_now }
end
end
# lib/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY, 'HS256')
end
def self.decode(token)
body = JWT.decode(token, SECRET_KEY, true, { algorithm: 'HS256' })[0]
HashWithIndifferentAccess.new(body)
rescue JWT::ExpiredSignature, JWT::DecodeError
nil
end
end
# app/controllers/concerns/authenticable.rb
module Authenticable
extend ActiveSupport::Concern
included do
before_action :authenticate_request
attr_reader :current_user
end
private
def authenticate_request
token = request.headers['Authorization']&.split(' ')&.last
decoded = JsonWebToken.decode(token)
if decoded
@current_user = User.find_by(id: decoded[:user_id])
render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user
else
render json: { error: 'Invalid token' }, status: :unauthorized
end
end
end
CORS Configuration: Cross-Origin Resource Sharing headers allow JavaScript from the frontend domain to make requests to the API domain. The rack-cors gem handles CORS middleware configuration.
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'https://app.example.com',
'http://localhost:3000',
'http://localhost:8080'
resource '/api/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true,
max_age: 86400
end
# Stricter configuration for production
allow do
origins 'https://cdn.example.com'
resource '/public/*',
headers: :any,
methods: [:get],
credentials: false
end
end
Serving the Initial HTML: Rails applications serve the minimal HTML shell that loads the JavaScript application. This typically involves a catch-all route that renders the same HTML for any non-API path.
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :posts
resources :comments
post 'login', to: 'authentication#login'
end
end
# Serve React app for all non-API routes
get '*path', to: 'application#fallback_index_html',
constraints: ->(request) { !request.xhr? && request.format.html? }
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
def fallback_index_html
render file: Rails.root.join('public', 'index.html')
end
end
Sinatra as Lightweight API: Sinatra provides a minimal framework for Ruby API backends without Rails' overhead.
# app.rb
require 'sinatra/base'
require 'sinatra/json'
require 'sinatra/cross_origin'
class ApiApp < Sinatra::Base
register Sinatra::CrossOrigin
configure do
enable :cross_origin
set :allow_origin, 'http://localhost:3000'
set :allow_methods, 'GET,POST,PUT,PATCH,DELETE,OPTIONS'
set :allow_headers, 'content-type,authorization'
end
before do
content_type :json
authenticate! unless ['/login', '/health'].include?(request.path_info)
end
get '/api/items' do
items = Item.all
json items: items.map(&:to_hash)
end
post '/api/items' do
data = JSON.parse(request.body.read)
item = Item.create(data)
if item.valid?
status 201
json item: item.to_hash
else
status 422
json errors: item.errors.full_messages
end
end
private
def authenticate!
token = request.env['HTTP_AUTHORIZATION']&.split(' ')&.last
@current_user = User.find_by_token(token)
halt 401, json({ error: 'Unauthorized' }) unless @current_user
end
end
Performance Considerations
Client-side rendering introduces distinct performance characteristics that differ significantly from server-rendered alternatives. Understanding these implications guides optimization strategies and architectural decisions.
JavaScript Bundle Size: CSR applications ship the entire application framework and code to the browser. React, Vue, or Angular frameworks add 50-150KB (minified and gzipped). Application code, dependencies, and third-party libraries accumulate quickly. A typical medium-sized SPA distributes 300KB-1MB of JavaScript, which takes seconds to download on slower connections and hundreds of milliseconds to parse and compile.
Code splitting mitigates bundle size by dividing the application into chunks that load on demand. Route-based splitting loads only the JavaScript required for the current page. Component-based splitting defers loading of features until activation.
// Route-based code splitting with React
import { lazy, Suspense } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
// These components load only when their routes activate
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Tree shaking removes unused code during the build process. Dead code elimination depends on ES modules and static imports. Dynamic imports prevent tree shaking since the bundler cannot determine which code paths execute.
Time to Interactive: CSR applications exhibit a gap between "First Contentful Paint" (when something appears) and "Time to Interactive" (when the page responds to input). The browser downloads JavaScript, parses it, executes framework initialization code, renders components, and fetches initial data before the page becomes interactive. This sequence takes multiple seconds on average mobile devices.
Server-side rendering shortens time to interactive by sending rendered HTML that displays immediately. The JavaScript still needs to download and execute for interactivity, but users see content sooner. Static site generation eliminates this gap entirely by pre-rendering all pages at build time.
Waterfall Requests: CSR applications often create request waterfalls. The browser loads HTML, then JavaScript, then executes code that fetches data, which may trigger additional data requests. Each step waits for the previous step to complete.
// Request waterfall in CSR
// 1. Load HTML (50ms)
// 2. Load JavaScript bundle (500ms)
// 3. JavaScript executes and fetches user (200ms)
// 4. User data arrives, then fetch posts (200ms)
// 5. Posts data arrives, display content
// Total: 950ms before showing content
function Profile() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
useEffect(() => {
// Fetch user data
fetch('/api/user/current')
.then(r => r.json())
.then(userData => {
setUser(userData);
// Only after user data arrives, fetch posts
return fetch(`/api/posts?author=${userData.id}`);
})
.then(r => r.json())
.then(setPosts);
}, []);
return user ? <UserProfile user={user} posts={posts} /> : null;
}
Parallel data fetching reduces waterfall delays by initiating all requests simultaneously rather than sequentially.
// Parallel fetching eliminates sequential dependency
function Profile() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
useEffect(() => {
// Start both requests simultaneously
Promise.all([
fetch('/api/user/current').then(r => r.json()),
fetch('/api/posts/recent').then(r => r.json())
]).then(([userData, postsData]) => {
setUser(userData);
setPosts(postsData);
});
}, []);
return user ? <UserProfile user={user} posts={posts} /> : null;
}
Caching Strategies: CSR applications benefit from aggressive caching since JavaScript bundles rarely change. Cache-busting through filename hashing (app.a1b2c3.js) allows infinite cache times for versioned assets while ensuring updates propagate immediately.
API response caching reduces server load and improves perceived performance. Client-side caches using localStorage or IndexedDB persist data across sessions. The Ruby backend sets appropriate cache headers for different endpoint types.
# Cache-Control headers for different content types
class Api::V1::PostsController < ApplicationController
def index
posts = Rails.cache.fetch('posts_index', expires_in: 5.minutes) do
Post.published.includes(:author).order(created_at: :desc).to_a
end
expires_in 5.minutes, public: true
render json: posts
end
def show
post = Post.find(params[:id])
# User-specific content shouldn't cache publicly
if post.draft?
expires_in 0, private: true, no_store: true
else
expires_in 1.hour, public: true
end
render json: post
end
end
Rendering Performance: Client-side frameworks perform work on every state change. React reconciles virtual DOM, Vue updates reactive dependencies, and Angular runs change detection. Unnecessary re-renders waste CPU cycles and drain battery on mobile devices.
Memoization prevents re-rendering when props haven't changed. Pure components only re-render when their inputs differ from previous renders. Computed properties cache derived data until dependencies update.
Network Request Optimization: Batching multiple API requests into a single roundtrip reduces overhead. GraphQL endpoints or custom batch endpoints combine related data fetches.
# Batch endpoint in Ruby backend
class Api::V1::BatchController < ApplicationController
def query
queries = params[:queries].map(&:symbolize_keys)
results = queries.map do |query|
execute_query(query[:type], query[:id], query[:params])
end
render json: { results: results }
end
private
def execute_query(type, id, params)
case type
when 'user'
UserSerializer.new(User.find(id)).serializable_hash
when 'posts'
PostSerializer.new(Post.where(params), is_collection: true).serializable_hash
when 'comments'
CommentSerializer.new(Comment.where(params), is_collection: true).serializable_hash
end
rescue ActiveRecord::RecordNotFound
{ error: 'Not found' }
end
end
Compression (gzip or brotli) reduces transfer sizes significantly for text-based assets. The Ruby backend enables compression middleware, and the CDN applies additional compression.
Security Implications
Client-side rendering creates security considerations distinct from server-rendered applications. The client environment remains untrusted—any validation, authorization, or sensitive logic executing in JavaScript can be inspected, modified, or bypassed.
XSS Vulnerabilities: CSR frameworks typically escape output automatically, but manual DOM manipulation or dangerously setting HTML introduces XSS risks. Any user-generated content rendered without sanitization allows attackers to inject scripts that steal tokens, modify page content, or perform actions as the user.
// VULNERABLE: Direct HTML insertion
function CommentDisplay({ comment }) {
return (
<div dangerouslySetInnerHTML={{ __html: comment.body }} />
);
}
// SECURE: Framework handles escaping
function CommentDisplay({ comment }) {
return <div>{comment.body}</div>;
}
// SECURE: Sanitize HTML before rendering
import DOMPurify from 'dompurify';
function CommentDisplay({ comment }) {
const sanitized = DOMPurify.sanitize(comment.body, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href']
});
return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}
The Ruby backend must sanitize user input before storage, but the frontend requires additional sanitization since attackers may inject content directly into the database through other vectors.
Authentication Token Storage: JWT tokens or session identifiers require secure storage in the browser. localStorage offers convenience but exposes tokens to XSS attacks—malicious scripts access localStorage without restriction. HttpOnly cookies protect against XSS but complicate CORS and cross-domain authentication.
Memory-only storage (JavaScript variables) provides the strongest XSS protection since tokens never persist anywhere accessible to scripts. However, this forces re-authentication on page refresh.
# Ruby backend sets HttpOnly cookie with JWT
class Api::V1::AuthenticationController < ApplicationController
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
cookies.signed[:auth_token] = {
value: token,
httponly: true,
secure: Rails.env.production?,
same_site: :strict,
expires: 24.hours.from_now
}
render json: { user: UserSerializer.new(user).serializable_hash }
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
end
API Authorization: Every API endpoint must verify authorization server-side. Client-side checks improve UX by hiding unauthorized actions, but provide no security. Attackers bypass frontend code and call APIs directly.
# Ruby backend enforces authorization
class Api::V1::PostsController < ApplicationController
def update
post = Post.find(params[:id])
# Server validates ownership regardless of frontend
unless current_user.admin? || post.author_id == current_user.id
return render json: { error: 'Forbidden' }, status: :forbidden
end
if post.update(post_params)
render json: post
else
render json: { errors: post.errors }, status: :unprocessable_entity
end
end
end
CSRF Protection: API-only backends typically disable CSRF protection since state management occurs client-side through tokens rather than server-side sessions. However, if using cookies for authentication, CSRF attacks remain possible. SameSite cookie attributes and custom headers mitigate CSRF.
# CSRF protection for cookie-based auth
class ApplicationController < ActionController::API
include ActionController::RequestForgeryProtection
protect_from_forgery with: :exception, if: -> { cookies[:auth_token].present? }
# Require custom header to prevent simple CSRF
before_action :verify_custom_header
private
def verify_custom_header
unless request.headers['X-Requested-With'] == 'XMLHttpRequest'
render json: { error: 'Forbidden' }, status: :forbidden
end
end
end
Sensitive Data Exposure: CSR applications receive all data through API responses visible in browser network tabs. Never include sensitive information (passwords, private keys, internal IDs) in API responses. Serialize only data the current user should access.
# Control serialized data based on user permissions
class UserSerializer
include FastJsonapi::ObjectSerializer
attributes :id, :username, :display_name, :avatar_url
# Conditionally include email only for self or admins
attribute :email, if: Proc.new { |user, params|
params[:current_user]&.admin? || params[:current_user]&.id == user.id
}
# Never serialize sensitive fields
# Password hashes, tokens, API keys stay server-side
end
Content Security Policy: CSR applications benefit from strict Content Security Policy headers that restrict script sources, prevent inline script execution, and limit external resource loading.
# config/initializers/content_security_policy.rb
Rails.application.config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self, 'https://cdn.example.com'
policy.style_src :self, 'https://cdn.example.com'
policy.img_src :self, :data, 'https:'
policy.connect_src :self, 'https://api.example.com'
# Report violations
policy.report_uri '/csp-violation-report'
end
Rails.application.config.content_security_policy_nonce_generator =
->(request) { SecureRandom.base64(16) }
Rate Limiting: Client-side applications make numerous API requests. Rate limiting prevents abuse and protects backend resources.
# Use rack-attack for rate limiting
class Rack::Attack
# Throttle API requests by IP
throttle('api/ip', limit: 300, period: 5.minutes) do |req|
req.ip if req.path.start_with?('/api/')
end
# Throttle login attempts
throttle('login/ip', limit: 5, period: 20.seconds) do |req|
req.ip if req.path == '/api/v1/login' && req.post?
end
# Throttle by authenticated user
throttle('api/user', limit: 1000, period: 1.hour) do |req|
req.env['current_user']&.id if req.path.start_with?('/api/')
end
end
Common Pitfalls
Developers frequently encounter recurring issues when building client-side rendered applications. Recognizing these patterns prevents costly mistakes and architectural problems.
SEO Neglect: Teams build entire marketing sites with CSR and discover search engines don't index the content. Google executes JavaScript but with limitations—crawling budgets limit how much JavaScript sites it renders, and ranking favors faster-loading pages. Other search engines provide even less JavaScript support.
The solution involves either switching to server-side rendering for public content or implementing prerendering services that generate static HTML for crawlers. Attempting to serve different content to crawlers risks search engine penalties.
Loading State Mismanagement: Applications show blank pages or infinite spinners when data fetching fails or takes longer than expected. Proper loading states communicate progress, error conditions offer retry mechanisms, and timeout handling prevents indefinite waiting.
// Incomplete loading state handling
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
}, []);
return user ? <div>{user.name}</div> : <Spinner />;
// Problem: Spinner shows forever if fetch fails
}
// Proper loading state management
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let mounted = true;
fetch('/api/user')
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
if (mounted) {
setUser(data);
setLoading(false);
}
})
.catch(err => {
if (mounted) {
setError(err.message);
setLoading(false);
}
});
return () => { mounted = false; };
}, []);
if (loading) return <Spinner />;
if (error) return <ErrorDisplay error={error} onRetry={() => window.location.reload()} />;
return <div>{user.name}</div>;
}
Memory Leaks: Single-page applications run for extended periods without page refreshes. Memory leaks accumulate until the browser tab becomes unresponsive. Common causes include uncleaned event listeners, uncleared timers, and retained references to detached DOM nodes.
// Memory leak: Timer not cleaned
function Countdown() {
const [seconds, setSeconds] = useState(60);
useEffect(() => {
setInterval(() => {
setSeconds(s => s - 1);
}, 1000);
// Problem: Interval continues after component unmounts
}, []);
return <div>{seconds}</div>;
}
// Proper cleanup
function Countdown() {
const [seconds, setSeconds] = useState(60);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(s => s - 1);
}, 1000);
return () => clearInterval(intervalId);
}, []);
return <div>{seconds}</div>;
}
Authentication State Sync: Token expiration and refresh logic requires careful coordination. Applications that fail to handle expired tokens show unauthorized errors or log users out unexpectedly. Implementing automatic token refresh before expiration and handling refresh failures gracefully improves reliability.
# Ruby backend refresh endpoint
class Api::V1::AuthenticationController < ApplicationController
def refresh
token = request.headers['Authorization']&.split(' ')&.last
decoded = JsonWebToken.decode(token)
if decoded && decoded[:exp] > Time.now.to_i - 10.minutes.to_i
user = User.find(decoded[:user_id])
new_token = JsonWebToken.encode(user_id: user.id)
render json: { token: new_token, expires_at: 24.hours.from_now }
else
render json: { error: 'Token expired or invalid' }, status: :unauthorized
end
end
end
State Management Complexity: Applications that hoist all state to the top level create prop drilling problems—passing data through multiple component levels to reach deeply nested children. Global state stores solve this but introduce different complexity when overused.
Component-local state serves most needs. Global state works for truly cross-cutting concerns like authentication, theme preferences, and shared data caches. Over-centralizing state creates unnecessary coupling and complicates testing.
API Response Coupling: Tightly coupling frontend code to API response shapes makes the application brittle. Backend changes to response structure break frontend code. Serialization layers in the Ruby backend and adapter layers in the frontend insulate against this coupling.
// Tightly coupled to API structure
function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(data => setPosts(data.posts));
}, []);
return posts.map(post => (
<div key={post.id}>
<h2>{post.attributes.title}</h2>
<p>{post.attributes.body}</p>
</div>
));
}
// Adapter layer decouples from API structure
function adaptPostData(apiResponse) {
return apiResponse.posts.map(post => ({
id: post.id,
title: post.attributes.title,
body: post.attributes.body,
author: post.relationships.author.data.attributes.name
}));
}
function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(data => setPosts(adaptPostData(data)));
}, []);
return posts.map(post => (
<div key={post.id}>
<h2>{post.title}</h2>
<p>{post.body}</p>
</div>
));
}
Build Configuration Errors: Production builds require different configuration than development. Missing environment variables, incorrect API endpoints, and disabled source maps cause runtime errors in production that never appeared during development. Staging environments that mirror production catch these issues before they reach users.
Browser History Misuse: Manipulating browser history incorrectly breaks back button functionality. Each distinct application state should correspond to a unique URL that restores that state when navigated to directly.
Reference
Client-Side Rendering Characteristics
| Aspect | Description | Consideration |
|---|---|---|
| Rendering Location | Browser executes JavaScript to generate DOM | Requires JavaScript enabled |
| Initial Load Speed | Slower due to JavaScript download and execution | Impacts first impression |
| Navigation Speed | Instant client-side transitions | Better UX for frequent navigation |
| SEO Support | Limited without additional techniques | Requires prerendering or SSR for public content |
| Server Load | Minimal - serves static assets and APIs | Lower infrastructure costs |
| Offline Capability | Possible with Service Workers | Enables Progressive Web Apps |
| Development Separation | Frontend and backend develop independently | API contracts define integration |
Ruby Backend API Patterns
| Pattern | Use Case | Implementation |
|---|---|---|
| RESTful Endpoints | CRUD operations on resources | Rails resources or Sinatra routes |
| GraphQL | Flexible data fetching | graphql-ruby gem |
| JWT Authentication | Stateless auth for APIs | jwt gem with custom middleware |
| Token Refresh | Extend sessions without re-login | Refresh endpoint before expiration |
| CORS Headers | Cross-origin API access | rack-cors middleware |
| API Versioning | Support multiple API versions | URL or header-based versioning |
| Rate Limiting | Prevent API abuse | rack-attack gem |
Performance Metrics
| Metric | Description | Target |
|---|---|---|
| Time to First Byte | Server response time | Under 200ms |
| First Contentful Paint | First visible content | Under 1.8s |
| Time to Interactive | Page fully interactive | Under 3.8s |
| JavaScript Bundle Size | Total JS transferred | Under 300KB gzipped |
| API Response Time | Backend response speed | Under 100ms p95 |
| Cache Hit Rate | Percentage served from cache | Above 80% |
Security Headers
| Header | Purpose | Example Value |
|---|---|---|
| Content-Security-Policy | Restrict resource loading | default-src 'self'; script-src 'self' cdn.example.com |
| X-Content-Type-Options | Prevent MIME sniffing | nosniff |
| X-Frame-Options | Prevent clickjacking | DENY or SAMEORIGIN |
| Strict-Transport-Security | Enforce HTTPS | max-age=31536000; includeSubDomains |
| X-XSS-Protection | Enable XSS filtering | 1; mode=block |
Common API Response Codes
| Code | Meaning | Usage |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST creating resource |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid input data |
| 401 | Unauthorized | Missing or invalid auth token |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource does not exist |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
Code Splitting Strategies
| Strategy | Description | Benefit |
|---|---|---|
| Route-based | Split by page/route | Load only current page code |
| Component-based | Split large components | Defer heavy features |
| Vendor splitting | Separate third-party code | Cache framework code separately |
| Dynamic imports | Load on demand | Reduce initial bundle |
| Preloading | Fetch likely-needed code | Balance bundle size with speed |
State Management Approaches
| Approach | Scope | Use Case |
|---|---|---|
| Component State | Single component | Local UI state |
| Lifted State | Parent component | Shared by few children |
| Context | Component subtree | Theme, auth, localization |
| Global Store | Entire application | Redux, MobX, Zustand |
| URL State | Browser location | Shareable application state |
| Server Cache | API responses | React Query, SWR |