Client-Server Architecture Explained

Introduction
Have you ever wondered what happens when you click a link, submit a form, or load a website? Behind every web interaction lies a fundamental architectural pattern that powers the internet: client-server architecture.
Understanding client-server architecture is crucial for every developer, whether you're building a simple website or a complex distributed system. This guide will take you from the basics to advanced concepts, with practical examples and real-world applications.
What You'll Learn
✅ Understand what client-server architecture is and why it matters
✅ Learn how the request-response cycle works in web applications
✅ Explore different architectural tiers (2-tier, 3-tier, N-tier)
✅ Understand stateful vs stateless server design
✅ Discover modern variations like microservices and serverless
✅ Learn best practices and common pitfalls to avoid
What is Client-Server Architecture?
Client-server architecture is a distributed computing model where tasks and workloads are divided between clients (requesters of services) and servers (providers of services).
The Core Concept
In simple terms:
- Client: The application or device that requests data or services (e.g., your web browser, mobile app)
- Server: The system that provides data or services in response to client requests (e.g., web server, database server)
Why Client-Server Architecture?
Before client-server architecture became dominant, we had:
-
Mainframe Computing (1960s-1970s):
- All processing on a central computer
- Users accessed via "dumb terminals" with no processing power
- Expensive and limited scalability
-
Standalone Personal Computers (1980s):
- Each computer worked independently
- Difficult to share data and resources
- Duplication of software and data
Client-server architecture solved these problems by:
✅ Centralized data management: Single source of truth on servers
✅ Scalability: Add more servers as demand grows
✅ Resource sharing: Multiple clients access shared resources
✅ Security: Centralized access control and data protection
✅ Maintenance: Update server once instead of every client
How the Web Works: The Request-Response Cycle
Every interaction on the web follows a request-response cycle. Let's break down what happens when you visit a website like https://example.com.
Step-by-Step Breakdown
1. User Action (Client-Side)
User types URL in browser and presses Enter2. DNS Lookup
Browser asks DNS server: "What is the IP address of example.com?"
DNS responds: "93.184.216.34"3. HTTP Request (Client → Server)
GET / HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
Accept: text/html,application/xhtml+xml
Accept-Language: en-US,en;q=0.9
Connection: keep-alive4. Server Processing
Server receives request
Server authenticates (if required)
Server fetches data from database
Server renders HTML or prepares JSON response5. HTTP Response (Server → Client)
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 1234
Date: Thu, 30 Jan 2026 10:00:00 GMT
Server: nginx/1.18.0
<!DOCTYPE html>
<html>
<head>
<title>Example Website</title>
</head>
<body>
<h1>Welcome to Example.com</h1>
</body>
</html>6. Rendering (Client-Side)
Browser parses HTML
Browser fetches additional resources (CSS, JavaScript, images)
Browser renders the page
User sees the websiteReal-World Example: Login Flow
Let's see a more complex example with a user login:
// CLIENT SIDE (React/Next.js)
async function handleLogin(email: string, password: string) {
try {
// 1. Client sends POST request to server
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
// 2. Client receives response
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
// 3. Client stores token and redirects
localStorage.setItem('token', data.token);
window.location.href = '/dashboard';
} catch (error) {
console.error('Login error:', error);
}
}// SERVER SIDE (Express.js/Node.js)
app.post('/auth/login', async (req, res) => {
try {
// 1. Server receives request
const { email, password } = req.body;
// 2. Server validates input
if (!email || !password) {
return res.status(400).json({ error: 'Email and password required' });
}
// 3. Server queries database
const user = await db.users.findOne({ email });
// 4. Server verifies password
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 5. Server generates token
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
// 6. Server sends response
res.json({
token,
user: {
id: user.id,
email: user.email,
name: user.name,
},
});
} catch (error) {
res.status(500).json({ error: 'Internal server error' });
}
});Client-Side vs Server-Side: Understanding the Division
One of the most important concepts in web development is understanding what runs where.
Client-Side (Frontend)
Definition: Code that runs in the user's browser or device.
Technologies:
- HTML, CSS, JavaScript
- React, Vue, Angular, Svelte
- Mobile apps (iOS, Android)
Responsibilities:
- Rendering user interface
- Handling user interactions (clicks, form inputs)
- Client-side validation
- Making API requests to server
- Managing local state
- Routing (in Single Page Applications)
Example: Client-Side Validation
// Runs in the browser
function validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!regex.test(email)) {
alert('Invalid email format');
return false;
}
return true;
}
// This validation is for UX only
// Server MUST validate again for securityAdvantages:
- ✅ Fast response to user actions (no network delay)
- ✅ Reduces server load
- ✅ Works offline (with service workers)
- ✅ Rich, interactive user experiences
Limitations:
- ❌ Cannot access databases directly
- ❌ Cannot keep secrets (all code is visible)
- ❌ Cannot perform secure operations
- ❌ Limited by device capabilities
Server-Side (Backend)
Definition: Code that runs on servers (remote computers).
Technologies:
- Node.js (JavaScript/TypeScript)
- Python (Django, FastAPI, Flask)
- Java (Spring Boot)
- Go, Ruby, PHP, etc.
Responsibilities:
- Database operations (CRUD)
- Business logic
- Authentication and authorization
- Data validation and sanitization
- File storage and processing
- Third-party API integration
- Sending emails, notifications
Example: Server-Side Processing
// Runs on the server
import { db } from './database';
import { sendEmail } from './email-service';
app.post('/api/orders', async (req, res) => {
const { userId, items, total } = req.body;
// 1. Validate (server-side validation is REQUIRED)
if (!userId || !items || items.length === 0) {
return res.status(400).json({ error: 'Invalid order data' });
}
// 2. Check inventory (only server can access database)
const availableItems = await db.inventory.checkStock(items);
if (!availableItems) {
return res.status(400).json({ error: 'Some items are out of stock' });
}
// 3. Process payment (secure operation, only on server)
const payment = await stripe.charges.create({
amount: total * 100,
currency: 'usd',
// ...
});
// 4. Create order in database
const order = await db.orders.create({
userId,
items,
total,
paymentId: payment.id,
status: 'confirmed',
});
// 5. Send confirmation email
await sendEmail({
to: user.email,
subject: 'Order Confirmation',
template: 'order-confirmation',
data: { order },
});
res.json({ orderId: order.id, status: 'success' });
});Advantages:
- ✅ Secure (code and secrets not exposed)
- ✅ Access to databases and file systems
- ✅ Powerful processing capabilities
- ✅ Can handle heavy computations
- ✅ Centralized business logic
Limitations:
- ❌ Network latency (takes time to communicate)
- ❌ Requires internet connection
- ❌ Server costs and maintenance
The Golden Rule
Never trust the client. Always validate and process sensitive operations on the server.
Even if you have client-side validation, always validate again on the server. Client-side code can be bypassed by malicious users.
Thick Clients vs Thin Clients
Not all clients are created equal. There are two main philosophies:
Thin Clients
Definition: Clients that do minimal processing and rely heavily on servers.
Characteristics:
- Most logic runs on the server
- Client primarily displays data
- Server renders HTML (Server-Side Rendering)
- Less JavaScript on client
Example: Traditional server-rendered websites
User clicks link → Server generates full HTML page → Client displays itTechnologies:
- Traditional PHP, Ruby on Rails, Django
- Next.js with Server Components
- Blade templates, EJS, Pug
Advantages:
- ✅ Fast initial page load
- ✅ Better SEO (HTML fully rendered)
- ✅ Works on low-powered devices
- ✅ Less client-side code to maintain
Disadvantages:
- ❌ Full page reloads on navigation
- ❌ Higher server load
- ❌ Less interactive user experience
Thick Clients (Rich Clients)
Definition: Clients that do significant processing and logic.
Characteristics:
- Most UI logic runs on the client
- Server provides data via APIs (JSON)
- Client renders UI dynamically
- Heavy use of JavaScript
Example: Single Page Applications (SPAs)
User clicks button → Client updates UI instantly → Background API call → Update stateTechnologies:
- React, Vue, Angular SPAs
- Mobile apps (iOS, Android, React Native)
- Desktop apps (Electron)
Advantages:
- ✅ Highly interactive experiences
- ✅ No page reloads (faster navigation)
- ✅ Can work offline (with caching)
- ✅ Reduced server rendering load
Disadvantages:
- ❌ Slower initial load (large JavaScript bundles)
- ❌ SEO challenges (requires SSR or pre-rendering)
- ❌ More complex state management
- ❌ Requires more powerful devices
The Modern Approach: Hybrid
Modern frameworks like Next.js, Nuxt.js, and SvelteKit combine both approaches:
// Server Component (Thin Client approach)
async function BlogPost({ slug }: { slug: string }) {
// This runs on the server
const post = await db.posts.findOne({ slug });
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Client Component for interactivity */}
<CommentSection postId={post.id} />
</article>
);
}
// Client Component (Thick Client approach)
'use client';
function CommentSection({ postId }: { postId: string }) {
const [comments, setComments] = useState([]);
// This runs in the browser
async function addComment(text: string) {
const response = await fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ postId, text }),
});
const newComment = await response.json();
setComments([...comments, newComment]);
}
return (
<div>
{comments.map(comment => (
<Comment key={comment.id} {...comment} />
))}
<CommentForm onSubmit={addComment} />
</div>
);
}Architectural Tiers: 2-Tier, 3-Tier, and N-Tier
Client-server applications can be organized into different tiers (layers).
2-Tier Architecture (Client-Server)
Structure: Client ↔ Database Server
Example: Desktop applications connecting directly to databases
- Microsoft Access applications
- Early client-server applications
Advantages:
- ✅ Simple architecture
- ✅ Fast communication (no middle layer)
- ✅ Easy to develop for small applications
Disadvantages:
- ❌ Security risk (clients directly access database)
- ❌ Difficult to scale
- ❌ Business logic scattered across clients
- ❌ Hard to maintain and update
When to use: Small internal applications with trusted users.
3-Tier Architecture (Most Common)
Structure: Client ↔ Application Server ↔ Database
Example: Modern web applications
// TIER 1: Client (React)
function UserProfile() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/users/me')
.then(res => res.json())
.then(data => setUser(data));
}, []);
return <div>{user?.name}</div>;
}
// TIER 2: Application Server (Express.js)
app.get('/api/users/me', authenticateToken, async (req, res) => {
const userId = req.user.id;
const user = await db.users.findById(userId);
res.json(user);
});
// TIER 3: Database (PostgreSQL)
// SELECT * FROM users WHERE id = $1Advantages:
- ✅ Separation of concerns
- ✅ Clients never directly access database (security)
- ✅ Business logic centralized on server
- ✅ Easy to scale each tier independently
- ✅ Can change database without affecting clients
Disadvantages:
- ❌ More complex than 2-tier
- ❌ Additional network latency (3 layers)
- ❌ Requires more infrastructure
When to use: Most modern web applications.
N-Tier Architecture (Microservices)
Structure: Multiple specialized services
Example: Large-scale applications like Netflix, Amazon, Uber
Advantages:
- ✅ Each service can scale independently
- ✅ Different technologies for different services
- ✅ Team autonomy (each service owned by a team)
- ✅ Fault isolation (one service failure doesn't crash entire system)
- ✅ Easier to understand individual services
Disadvantages:
- ❌ Operational complexity (many services to manage)
- ❌ Network latency (services communicate over network)
- ❌ Data consistency challenges
- ❌ Requires DevOps expertise
- ❌ Distributed system challenges (debugging, monitoring)
When to use: Large applications with multiple teams, requiring independent scaling.
Stateful vs Stateless Servers
One of the most important architectural decisions is whether your server maintains state between requests.
Stateful Servers
Definition: The server remembers information about clients between requests.
How it works:
1. Client logs in → Server creates session, stores in memory
2. Client makes request → Server looks up session data
3. Client makes another request → Server uses same session
4. Client logs out → Server destroys sessionExample: Traditional session-based authentication
// Server stores session data in memory or Redis
app.post('/login', (req, res) => {
const { username, password } = req.body;
// Validate credentials
const user = validateUser(username, password);
// Create session (server remembers this user)
req.session.userId = user.id;
req.session.username = user.username;
res.json({ message: 'Logged in' });
});
app.get('/profile', (req, res) => {
// Server retrieves session data
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = db.users.findById(req.session.userId);
res.json(user);
});Advantages:
- ✅ Simpler to implement
- ✅ Can store complex user state on server
- ✅ Easy to invalidate sessions (logout)
Disadvantages:
- ❌ Difficult to scale horizontally (session data on one server)
- ❌ Requires sticky sessions (load balancer sends user to same server)
- ❌ Server memory grows with active users
- ❌ Sessions lost if server restarts
Stateless Servers
Definition: The server does NOT remember anything between requests. Each request contains all necessary information.
How it works:
1. Client logs in → Server generates JWT token, sends to client
2. Client stores token (localStorage or cookie)
3. Client makes request with token in header
4. Server validates token (no session lookup needed)Example: JWT-based authentication
// Server doesn't store anything
app.post('/login', (req, res) => {
const { username, password } = req.body;
const user = validateUser(username, password);
// Create token (contains user info, signed by server)
const token = jwt.sign(
{ userId: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Server doesn't store anything
res.json({ token });
});
app.get('/profile', (req, res) => {
// Extract token from header
const token = req.headers.authorization?.split(' ')[1];
try {
// Validate token (no database or session lookup)
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Get user data
const user = db.users.findById(decoded.userId);
res.json(user);
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});Advantages:
- ✅ Easy to scale horizontally (any server can handle any request)
- ✅ No session storage needed
- ✅ Servers can be added/removed without affecting clients
- ✅ Better for distributed systems
Disadvantages:
- ❌ Cannot invalidate tokens easily (until expiration)
- ❌ Tokens can be larger than session IDs
- ❌ Need secure token storage on client
The Modern Choice
Most modern APIs are stateless because:
- Easier to scale (add more servers without worrying about sessions)
- Better for microservices (services don't share state)
- Works well with serverless (functions are inherently stateless)
However, stateful approaches are still used for:
- WebSocket connections (real-time chat, gaming)
- Shopping carts (complex temporary state)
- Admin panels with complex workflows
Client-Server Communication Patterns
1. Request-Response (Synchronous)
Most common pattern: Client sends request, waits for response.
// Client sends, waits for response
const response = await fetch('/api/data');
const data = await response.json();
console.log(data);Use cases:
- Loading data for a page
- Form submissions
- API calls
Pros: Simple, reliable Cons: Client blocked until response arrives
2. Polling
Pattern: Client periodically asks server for updates.
// Check for new messages every 5 seconds
setInterval(async () => {
const response = await fetch('/api/messages/new');
const newMessages = await response.json();
if (newMessages.length > 0) {
displayMessages(newMessages);
}
}, 5000);Use cases:
- Checking for notifications
- Monitoring job status
Pros: Simple to implement Cons: Wastes bandwidth, not real-time
3. Long Polling
Pattern: Client sends request, server holds it open until it has data.
async function longPoll() {
const response = await fetch('/api/updates');
const data = await response.json();
handleUpdate(data);
// Immediately start next poll
longPoll();
}Use cases:
- Chat applications (before WebSockets)
- Real-time dashboards
Pros: More efficient than polling Cons: Still uses many connections
4. WebSockets (Bi-directional)
Pattern: Persistent connection, both client and server can send messages anytime.
// Client
const socket = new WebSocket('ws://localhost:3000');
socket.onopen = () => {
socket.send(JSON.stringify({ type: 'join', room: 'general' }));
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
displayMessage(message);
};
// Send message
function sendMessage(text) {
socket.send(JSON.stringify({ type: 'message', text }));
}// Server (with ws library)
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 3000 });
wss.on('connection', (ws) => {
console.log('Client connected');
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
// Broadcast to all clients
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
});
});Use cases:
- Real-time chat
- Live notifications
- Multiplayer games
- Collaborative editing
Pros: True real-time, efficient Cons: More complex, requires persistent connections
5. Server-Sent Events (SSE)
Pattern: Server pushes updates to client over HTTP.
// Client
const eventSource = new EventSource('/api/events');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('New event:', data);
};
eventSource.addEventListener('notification', (event) => {
showNotification(JSON.parse(event.data));
});// Server
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Send event every 10 seconds
const interval = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 10000);
req.on('close', () => {
clearInterval(interval);
});
});Use cases:
- Live sports scores
- Stock price updates
- Server logs streaming
Pros: Simple, built into browsers, auto-reconnects Cons: One-way only (server → client)
Modern Variations of Client-Server Architecture
1. Microservices Architecture
Concept: Break monolithic server into small, independent services.
Traditional Monolith:
Microservices:
Real-world example: Netflix
- 700+ microservices
- Each service handles one responsibility
- Services communicate via APIs
Benefits:
- Independent deployment
- Technology diversity (each service can use different language/framework)
- Team autonomy
- Fault isolation
Challenges:
- Distributed system complexity
- Network latency
- Data consistency
2. Serverless / Function-as-a-Service (FaaS)
Concept: No servers to manage. Code runs in response to events.
// AWS Lambda function (serverless)
export async function handler(event: APIGatewayEvent) {
const { userId } = JSON.parse(event.body || '{}');
// This function only runs when invoked
// AWS automatically scales based on requests
const user = await dynamoDB.get({
TableName: 'Users',
Key: { id: userId },
});
return {
statusCode: 200,
body: JSON.stringify(user),
};
}Example platforms:
- AWS Lambda
- Google Cloud Functions
- Azure Functions
- Vercel Functions
- Cloudflare Workers
Benefits:
- ✅ Auto-scaling (0 to millions of requests)
- ✅ Pay only for execution time
- ✅ No server management
- ✅ Fast deployment
Challenges:
- ❌ Cold starts (first request slower)
- ❌ Execution time limits (usually 15 minutes max)
- ❌ Vendor lock-in
- ❌ Difficult to debug
3. Edge Computing
Concept: Run code close to users (geographically distributed).
Traditional:
Edge Computing:
Example: Cloudflare Workers
// Runs on Cloudflare's edge network (200+ locations worldwide)
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// This code runs near the user
if (url.pathname === '/api/hello') {
return new Response('Hello from the edge!', {
headers: { 'content-type': 'text/plain' },
});
}
// Or proxy to origin server
return fetch(request);
},
};Benefits:
- ✅ Ultra-low latency
- ✅ Better performance globally
- ✅ Can cache at edge
Use cases:
- Content delivery (CDN)
- API acceleration
- A/B testing
- Bot detection
4. Peer-to-Peer (P2P)
Concept: Clients communicate directly with each other (no central server, or minimal server).
Traditional Client-Server:
P2P (Peer-to-Peer):
Examples:
- BitTorrent (file sharing)
- WebRTC (video calls)
- Blockchain networks
Benefits:
- Scales with number of users
- No single point of failure
- Lower bandwidth costs for servers
Challenges:
- NAT traversal (firewalls)
- Security and trust
- Discovery (how peers find each other)
Security Considerations in Client-Server Architecture
1. Never Trust the Client
// ❌ BAD: Client validates and sends price
// Client (easily manipulated)
function checkout(productId, price) {
// Malicious user can change price to $0.01
fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ productId, price: 0.01 }),
});
}
// ✅ GOOD: Server validates everything
// Server
app.post('/api/checkout', async (req, res) => {
const { productId } = req.body;
// Get real price from database (not from client)
const product = await db.products.findById(productId);
const actualPrice = product.price;
// Process payment with actual price
await processPayment(actualPrice);
});2. Authentication & Authorization
// Authentication: Who are you?
// Authorization: What can you do?
app.get('/api/admin/users',
authenticateToken, // Who are you?
requireAdmin, // Are you an admin?
async (req, res) => {
const users = await db.users.findAll();
res.json(users);
}
);
function authenticateToken(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
req.user = user;
next();
} catch (error) {
res.status(403).json({ error: 'Invalid token' });
}
}
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}3. Input Validation & Sanitization
import { z } from 'zod';
// Define schema
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
age: z.number().min(13).max(120),
});
app.post('/api/users', async (req, res) => {
try {
// Validate input
const validatedData = createUserSchema.parse(req.body);
// Sanitize (remove any HTML/scripts)
const sanitizedEmail = sanitize(validatedData.email);
// Now safe to use
const user = await db.users.create({
email: sanitizedEmail,
password: await hash(validatedData.password),
age: validatedData.age,
});
res.json(user);
} catch (error) {
res.status(400).json({ error: error.message });
}
});4. Rate Limiting
import rateLimit from 'express-rate-limit';
// Limit each IP to 100 requests per 15 minutes
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
message: 'Too many requests, please try again later',
});
app.use('/api/', limiter);5. HTTPS Only
// Force HTTPS in production
app.use((req, res, next) => {
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
next();
});Best Practices for Client-Server Architecture
1. Design Your API First
Before building, design your API contract:
# OpenAPI specification
/api/users:
get:
summary: Get all users
responses:
200:
description: Success
schema:
type: array
items:
$ref: '#/definitions/User'
post:
summary: Create user
parameters:
- name: body
in: body
schema:
$ref: '#/definitions/CreateUser'
responses:
201:
description: Created2. Use Standard HTTP Methods
// ✅ GOOD: RESTful design
GET /api/users // List users
GET /api/users/:id // Get single user
POST /api/users // Create user
PUT /api/users/:id // Update user (full)
PATCH /api/users/:id // Update user (partial)
DELETE /api/users/:id // Delete user
// ❌ BAD: Everything as POST
POST /api/getUsers
POST /api/createUser
POST /api/updateUser
POST /api/deleteUser3. Handle Errors Gracefully
// Server: Return meaningful errors
app.get('/api/users/:id', async (req, res) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) {
return res.status(404).json({
error: 'User not found',
code: 'USER_NOT_FOUND',
});
}
res.json(user);
} catch (error) {
console.error('Database error:', error);
res.status(500).json({
error: 'Internal server error',
code: 'INTERNAL_ERROR',
});
}
});
// Client: Handle all error cases
async function fetchUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('User not found');
} else if (response.status === 500) {
throw new Error('Server error, please try again');
} else {
throw new Error('Something went wrong');
}
}
return await response.json();
} catch (error) {
console.error('Error fetching user:', error);
throw error;
}
}4. Implement Caching
// Server-side caching
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 600 }); // 10 minutes
app.get('/api/products', async (req, res) => {
// Check cache first
const cached = cache.get('products');
if (cached) {
return res.json(cached);
}
// Fetch from database
const products = await db.products.findAll();
// Store in cache
cache.set('products', products);
res.json(products);
});
// HTTP caching headers
app.get('/api/products/:id', async (req, res) => {
const product = await db.products.findById(req.params.id);
// Cache for 1 hour
res.setHeader('Cache-Control', 'public, max-age=3600');
res.json(product);
});5. Use Pagination for Lists
// ❌ BAD: Return all 1 million users
app.get('/api/users', async (req, res) => {
const users = await db.users.findAll();
res.json(users); // 😱 Huge response
});
// ✅ GOOD: Paginate
app.get('/api/users', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const users = await db.users.findAll({ limit, offset });
const total = await db.users.count();
res.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
});6. Separate Environments
// Development
DATABASE_URL=localhost:5432
API_URL=http://localhost:3000
DEBUG=true
// Production
DATABASE_URL=prod-db.example.com:5432
API_URL=https://api.example.com
DEBUG=falseCommon Pitfalls to Avoid
1. Chatty APIs (Too Many Requests)
// ❌ BAD: N+1 problem
// Client makes 1 request to get posts, then N requests for authors
async function loadBlog() {
const posts = await fetch('/api/posts').then(r => r.json());
for (const post of posts) {
const author = await fetch(`/api/users/${post.authorId}`).then(r => r.json());
post.author = author;
}
}
// ✅ GOOD: Include related data in one request
async function loadBlog() {
// Server returns posts with authors included
const posts = await fetch('/api/posts?include=author').then(r => r.json());
}2. Exposing Sensitive Data
// ❌ BAD: Returning password hash
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user); // Contains passwordHash, email, etc.
});
// ✅ GOOD: Return only public data
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json({
id: user.id,
name: user.name,
avatar: user.avatar,
createdAt: user.createdAt,
// No password, email, or sensitive info
});
});3. Synchronous Operations Blocking Server
// ❌ BAD: Blocking operation
app.post('/api/reports', (req, res) => {
// This blocks the server for 30 seconds
const report = generateLargeReport(); // 30 seconds
res.json(report);
});
// ✅ GOOD: Use background jobs
import { queue } from './job-queue';
app.post('/api/reports', async (req, res) => {
const jobId = await queue.add('generate-report', { userId: req.user.id });
res.json({
message: 'Report generation started',
jobId,
statusUrl: `/api/jobs/${jobId}`,
});
});4. Not Handling Network Failures
// ❌ BAD: No retry or error handling
async function saveData(data) {
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(data),
});
}
// ✅ GOOD: Retry with exponential backoff
async function saveData(data, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch('/api/save', {
method: 'POST',
body: JSON.stringify(data),
timeout: 5000,
});
if (response.ok) {
return await response.json();
}
throw new Error(`HTTP ${response.status}`);
} catch (error) {
if (i === retries - 1) {
throw error;
}
// Exponential backoff: 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
}Real-World Examples
Example 1: E-Commerce Application
Architecture:
Flow: User adds item to cart → Frontend calls /api/orders → Order service checks inventory → Processes payment → Sends confirmation email → Updates UI
Example 2: Social Media Platform
Architecture:
Flow: User posts photo → Upload to S3 → Create post in DB → Invalidate feed cache → Push notification to followers → Real-time update via WebSocket
Example 3: Real-Time Chat Application
Architecture:
Flow: User sends message → WebSocket to server → Server publishes to Redis → All connected servers receive → Broadcast to recipients → Store in MongoDB
Summary and Key Takeaways
Let's recap the essential concepts:
✅ Client-server architecture divides work between clients (requesters) and servers (providers)
✅ Request-response cycle is how clients and servers communicate (HTTP requests/responses)
✅ Client-side handles UI and user interactions; server-side handles data and business logic
✅ Never trust the client - always validate and secure operations on the server
✅ Thin clients rely on servers; thick clients do more processing locally
✅ 3-tier architecture (client-app-database) is the most common pattern
✅ Stateless servers scale better than stateful ones (use JWT instead of sessions)
✅ Modern variations include microservices, serverless, and edge computing
✅ Security requires authentication, authorization, input validation, and rate limiting
✅ Best practices: Design API first, use proper HTTP methods, cache data, paginate lists
What's Next?
Now that you understand client-server architecture, continue your learning journey with these related topics:
📚 HTTP Protocol Complete Guide - Learn the language clients and servers speak
📚 What is REST API? - Design APIs that follow best practices
📚 TypeScript Backend Development - Build production-ready servers
📚 Docker Complete Guide - Containerize your client-server applications
Conclusion
Client-server architecture is the backbone of the internet and modern applications. Whether you're building a simple website or a complex distributed system, understanding how clients and servers communicate is fundamental to being an effective developer.
The key is to think in terms of responsibilities: What should the client do? What should the server do? Where does data flow? How do we ensure security and scalability?
As you build applications, you'll naturally develop intuition for these architectural decisions. Start simple with 3-tier architecture, follow best practices, and evolve your architecture as your application grows.
Remember: The best architecture is the simplest one that meets your requirements. Don't over-engineer, but don't under-architect either. Find the right balance for your use case.
Happy coding! 🚀
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.