Client-server and tiers
Client-server is the outer shape of almost every system, and tiers are how its server side is split across machines. This page builds Pedal in three tiers with stateless web servers and a shared session store, and measures how quickly chatty calls between tiers add up when the tiers drift apart.
1. In one picture
Client-server is the oldest and most common shape: clients ask, a server owns the data and the rules and answers. Almost every other style on this track sits inside it. Tiers describe how the server side is split across machines: unlike layers, which split code inside one program, tiers are physically separate and talk over a network.
| Shape | Machines | Where it still fits |
|---|---|---|
| Two-tier | Client talks straight to the database | Internal desktop tools on a trusted network; mostly retired |
| Three-tier | Client · application servers · database | The default for web and mobile backends |
| Four-tier and beyond | Client · web or API tier · application tier · data tier (plus caches, search…) | Larger systems that scale and secure each tier separately |
2. Pedal built this way
Mia's three requests land on different web servers. Because the web tier keeps no state of its own, it doesn't matter which one answers.
The rule that makes this work: servers in a tier are stateless. Anything that must survive between requests (sessions, carts, ride state) lives in a shared store or the data tier. Then a load balancer can send any request anywhere, a crashed server loses nothing, and scaling a tier means adding identical copies (System design module 08).
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| Clients never touch the data tier | The server enforces the rules and protects the data. | The phone app has database credentials compiled into it. |
| Servers in a tier are stateless | Any copy can serve any request; scaling is adding copies. | A web server keeps sessions in its own memory, so users are logged out when it restarts. |
| Each tier talks only to the next | Each tier can be secured, scaled and replaced on its own. | The web tier queries the database "for speed". |
| Calls between tiers are few and coarse | Every tier hop is a network round trip that can be slow or fail. | One page makes 40 small queries from the app tier to the database. |
4. How it goes wrong
5. The code
Pedal's three tiers in one program: two interchangeable web servers behind round-robin balancing, a shared session store, an application tier with the rules, and a data tier. A request with an unknown session is refused, whichever server gets it.
from __future__ import annotations
from itertools import cycle
# Each class below would run on its own machines: a tier. Calls between them would be network calls.
class DataTier: # the database servers
def __init__(self) -> None:
self.bikes = {"D12": ["B7", "B9"]}
self.rides: dict[str, dict] = {}
def take_bike(self, dock: str) -> str:
return self.bikes[dock].pop(0)
def save_ride(self, ride_id: str, ride: dict) -> None:
self.rides[ride_id] = ride
class AppTier: # business rules, behind the web tier
def __init__(self, data: DataTier) -> None:
self.data = data
def unlock(self, rider: str, dock: str) -> str:
bike = self.data.take_bike(dock)
ride_id = f"R{len(self.data.rides) + 1}"
self.data.save_ride(ride_id, {"rider": rider, "bike": bike})
return f"{ride_id} on {bike}"
def price(self, plan: str, minutes: int) -> str:
p = max(0, minutes - 30) * 10 if plan == "member" else 100 + minutes * 10
return f"£{p // 100}.{p % 100:02d}"
class SessionStore: # shared by every web server (e.g. Redis)
def __init__(self) -> None:
self.sessions: dict[str, dict] = {}
class WebServer: # presentation tier: stateless, many identical copies
def __init__(self, name: str, app: AppTier, sessions: SessionStore) -> None:
self.name, self.app, self.sessions = name, app, sessions
def handle(self, request: dict) -> str:
if request["path"] == "/login":
token = f"s{len(self.sessions.sessions) + 1}"
self.sessions.sessions[token] = {"rider": request["rider"], "plan": request["plan"]}
return f"{self.name}: logged in {request['rider']}, session {token} stored in the shared store"
session = self.sessions.sessions.get(request["token"])
if session is None:
return f"{self.name}: 401 no session"
if request["path"] == "/unlock":
return f"{self.name}: session {request['token']} found, unlocked {self.app.unlock(session['rider'], request['dock'])}"
return f"{self.name}: session {request['token']} found, return after {request['minutes']} min costs {self.app.price(session['plan'], request['minutes'])}"
if __name__ == "__main__":
data = DataTier()
app = AppTier(data)
sessions = SessionStore()
balancer = cycle([WebServer("web-1", app, sessions), WebServer("web-2", app, sessions)]) # round robin
requests = [{"path": "/login", "rider": "Mia", "plan": "member"},
{"path": "/unlock", "token": "s1", "dock": "D12"},
{"path": "/return", "token": "s1", "minutes": 42},
{"path": "/unlock", "token": "s9", "dock": "D12"}]
for r in requests:
print(next(balancer).handle(r))
print("tiers: client -> web (stateless) -> app -> data")
// Each class below would run on its own machines: a tier. Calls between them would be network calls.
class DataTier { // the database servers
bikes: Record<string, string[]> = { D12: ["B7", "B9"] };
rides = new Map<string, { rider: string; bike: string }>();
takeBike(dock: string): string { return this.bikes[dock].shift() as string; }
saveRide(id: string, ride: { rider: string; bike: string }): void { this.rides.set(id, ride); }
}
class AppTier { // business rules, behind the web tier
constructor(private data: DataTier) {}
unlock(rider: string, dock: string): string {
const bike = this.data.takeBike(dock);
const id = `R${this.data.rides.size + 1}`;
this.data.saveRide(id, { rider, bike });
return `${id} on ${bike}`;
}
price(plan: string, minutes: number): string {
const p = plan === "member" ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10;
return `£${Math.floor(p / 100)}.${String(p % 100).padStart(2, "0")}`;
}
}
class SessionStore { sessions = new Map<string, { rider: string; plan: string }>(); } // shared by every web server (e.g. Redis)
interface Req { path: string; rider?: string; plan?: string; token?: string; dock?: string; minutes?: number; }
class WebServer { // presentation tier: stateless, many identical copies
constructor(private name: string, private app: AppTier, private store: SessionStore) {}
handle(r: Req): string {
if (r.path === "/login") {
const token = `s${this.store.sessions.size + 1}`;
this.store.sessions.set(token, { rider: r.rider ?? "", plan: r.plan ?? "" });
return `${this.name}: logged in ${r.rider}, session ${token} stored in the shared store`;
}
const s = this.store.sessions.get(r.token ?? "");
if (!s) return `${this.name}: 401 no session`;
if (r.path === "/unlock") return `${this.name}: session ${r.token} found, unlocked ${this.app.unlock(s.rider, r.dock ?? "")}`;
return `${this.name}: session ${r.token} found, return after ${r.minutes} min costs ${this.app.price(s.plan, r.minutes ?? 0)}`;
}
}
const data = new DataTier(), app = new AppTier(data), sessions = new SessionStore();
const servers = [new WebServer("web-1", app, sessions), new WebServer("web-2", app, sessions)];
const requests: Req[] = [{ path: "/login", rider: "Mia", plan: "member" }, { path: "/unlock", token: "s1", dock: "D12" },
{ path: "/return", token: "s1", minutes: 42 }, { path: "/unlock", token: "s9", dock: "D12" }];
requests.forEach((r, i) => console.log(servers[i % servers.length].handle(r))); // round robin
console.log("tiers: client -> web (stateless) -> app -> data");import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ArrayList;
import java.util.Map;
// Each class below would run on its own machines: a tier. Calls between them would be network calls.
class DataTier { // the database servers
final Map<String, List<String>> bikes = new HashMap<>(Map.of("D12", new ArrayList<>(List.of("B7", "B9"))));
final Map<String, String[]> rides = new LinkedHashMap<>();
String takeBike(String dock) { return bikes.get(dock).remove(0); }
void saveRide(String id, String rider, String bike) { rides.put(id, new String[]{rider, bike}); }
}
class AppTier { // business rules, behind the web tier
private final DataTier data;
AppTier(DataTier data) { this.data = data; }
String unlock(String rider, String dock) {
String bike = data.takeBike(dock);
String id = "R" + (data.rides.size() + 1);
data.saveRide(id, rider, bike);
return id + " on " + bike;
}
String price(String plan, int minutes) {
int p = plan.equals("member") ? Math.max(0, minutes - 30) * 10 : 100 + minutes * 10;
return "£" + (p / 100) + "." + String.format("%02d", p % 100);
}
}
class SessionStore { final Map<String, String[]> sessions = new HashMap<>(); } // shared by every web server (e.g. Redis)
class WebServer { // presentation tier: stateless, many identical copies
private final String name; private final AppTier app; private final SessionStore store;
WebServer(String name, AppTier app, SessionStore store) { this.name = name; this.app = app; this.store = store; }
String handle(Map<String, String> r) {
if (r.get("path").equals("/login")) {
String token = "s" + (store.sessions.size() + 1);
store.sessions.put(token, new String[]{r.get("rider"), r.get("plan")});
return name + ": logged in " + r.get("rider") + ", session " + token + " stored in the shared store";
}
String[] s = store.sessions.get(r.get("token"));
if (s == null) return name + ": 401 no session";
if (r.get("path").equals("/unlock")) return name + ": session " + r.get("token") + " found, unlocked " + app.unlock(s[0], r.get("dock"));
return name + ": session " + r.get("token") + " found, return after " + r.get("minutes") + " min costs " + app.price(s[1], Integer.parseInt(r.get("minutes")));
}
}
public class Main {
public static void main(String[] args) {
DataTier data = new DataTier();
AppTier app = new AppTier(data);
SessionStore sessions = new SessionStore();
List<WebServer> servers = List.of(new WebServer("web-1", app, sessions), new WebServer("web-2", app, sessions));
List<Map<String, String>> requests = List.of(Map.of("path", "/login", "rider", "Mia", "plan", "member"), Map.of("path", "/unlock", "token", "s1", "dock", "D12"),
Map.of("path", "/return", "token", "s1", "minutes", "42"), Map.of("path", "/unlock", "token", "s9", "dock", "D12"));
for (int i = 0; i < requests.size(); i++) System.out.println(servers.get(i % servers.size()).handle(requests.get(i))); // round robin
System.out.println("tiers: client -> web (stateless) -> app -> data");
}
}Output (the same in all three languages)
web-1: logged in Mia, session s1 stored in the shared store web-2: session s1 found, unlocked R1 on B7 web-1: session s1 found, return after 42 min costs £1.20 web-2: 401 no session tiers: client -> web (stateless) -> app -> data
6. Try it
Chatty tiers
One Pedal screen needs some database queries. Choose the shape, how many queries, and where the tiers sit. A phone's round trip to the servers is about 40 ms.
Round trips: phone to servers about 40 ms; between tiers about 0.5 ms in one zone, 1.5 ms across zones, 70 ms across regions; 5 ms of work in the application. Rough, but the right orders of magnitude.
7. What it costs, and when to pick another style
What you get
- Central control: the server owns data, rules and security.
- Independent scaling: each tier gets as many copies as it needs.
- Thin, replaceable clients: phone, web and kiosk share one backend.
- Clear security zones: only the web tier faces the internet; the database faces only the app tier.
What you pay
- Network hops: every tier adds latency and a way to fail.
- A central bottleneck: the data tier is usually the hardest to scale.
- The server is a single point of control: if it's down, every client is.
- More infrastructure: load balancers, sessions stores, firewalls per tier.
| If… | Consider instead |
|---|---|
| Clients must keep working offline or there's no trusted centre | Peer-to-peer or local-first |
| The server side itself needs splitting by business area | Service-based or microservices |
| Traffic is spiky and small | Serverless for the application tier |
| Callers shouldn't know which server handles them | Broker |
8. Signs you've outgrown it
- The data tier is saturated while the other tiers idle.
- One application tier holds everything, and teams queue to release it.
- Clients need to work while disconnected for long periods.
9. Remember it and say it
How this hook works: what “Ask at the desk” means
What the slogan means, phrase by phrase
- “Ask”
- Clients send requests; they never fetch data themselves.
- “at the desk”
- The server owns the data and rules; any desk clerk can help any reader.
Why this picture fits: A library: readers ask at the desk, librarians fetch from the guarded stacks, and loan records live in the system, not in a librarian's head.
The story → the concept
| The library | Pedal |
|---|---|
| Readers | Phones and the web app |
| The desk staff | Stateless web servers |
| The catalogue system | The shared session store |
| The stacks | The data tier |
Watch it on an example
- Mia's three requests hit two web servers; both know her session.
- A request with an unknown session gets a 401, wherever it lands.
- The phone never sees the database.
The trap it saves you from
Keeping sessions in one server's memory, then pinning users to it.
It still works when…
As the outside of every other style: microservices and serverless are client-server too.
In an interview or design review
- Draw the tiers and say they're stateless: "Web tier behind a load balancer, sessions in Redis, application tier, then the database with a replica."
- Know layers vs tiers: layers are code organisation in one process; tiers are separate machines.
- Count round trips on the hot path, and batch or cache when a screen needs many queries.
10. Check yourself
What's the difference between a layer and a tier?
Why must web servers in a tier be stateless?
A screen makes 30 queries and the application and database tiers move to different regions. What happens?
Why are two-tier designs rare for mobile apps?
11. Go deeper
| Resource | Why |
|---|---|
| System design 08: Load balancing and scaling out | Stateless tiers behind load balancers, in depth. |
| System design 02: Latency and the numbers to know | Where the round-trip numbers in the lab come from. |
| Architecture 10: Monolithic vs distributed | What changes when parts move to separate machines. |