J
Jyotimoy Kashyap Systems & Architecture
Architecture 8 min read 2026-09-13

API Servers at Scale — The Full Picture

From naive unpooled servers to thread pools, connection pools, and distributed server fleets.

#Architecture

API Servers at Scale — The Full Picture

Level 0: The Naive Setup — No Pools At All

The simplest possible server. Every request spawns a new thread and opens a new DB connection. No reuse, no pooling.

graph TB
    C1["👤 Client A"] --> S["🖥️ API Server"]
    C2["👤 Client B"] --> S

    subgraph S_INTERNAL ["Inside the Server"]
        direction TB
        REQ1["Request A arrives<br/>→ spawn new Thread<br/>→ open new DB connection"] 
        REQ2["Request B arrives<br/>→ spawn new Thread<br/>→ open new DB connection"]
    end

    REQ1 -- "new conn each time" --> DB["🗄️ Database"]
    REQ2 -- "new conn each time" --> DB

What goes wrong at scale:

ProblemWhy it hurts
Thread creation overheadSpawning a thread costs ~1ms + ~1MB stack memory. At 1000 req/sec, that’s 1000 threads and ~1GB RAM just for stacks
DB connection overheadOpening a connection takes ~20-50ms (TCP handshake + auth). That’s added to every request
No upper boundTraffic spike → unlimited threads → OS runs out of memory → crash
Connection exhaustionMost databases cap connections (e.g., PostgreSQL default: 100). 101st request gets rejected

This is fine for a hobby project doing 10 req/sec. It falls apart the moment you get real traffic.


Level 1 → Level 2: From Pooled Server to Fleet

Level 1 fixes Level 0 by introducing thread pools and connection pools — reuse instead of recreate. This is the standard single-server setup.

graph TB
    subgraph "Level 1 — What we started with"
        C_SIMPLE["👤 Clients"] --> S_SIMPLE["🖥️ API Server<br/><i>Thread Pool + Conn Pool</i>"]
        S_SIMPLE --> DB_SIMPLE["🗄️ Database"]
    end

Level 2 scales out by cloning that pair and putting a load balancer in front 👇

The Full Scaled Architecture

graph TB
    C1["👤 Client A<br/>user_id: 1-1000"] 
    C2["👤 Client B<br/>user_id: 1001-2000"]
    C3["👤 Client C<br/>user_id: 2001-3000"]
    C4["👤 Client D<br/>user_id: 1500"]

    LB["⚖️ Load Balancer<br/><i>Consistent Hashing on user_id</i><br/>nginx / HAProxy / AWS ALB"]

    C1 --> LB
    C2 --> LB
    C3 --> LB
    C4 --> LB

    subgraph SERVER_1 ["🖥️ API Server 1 (Process)"]
        direction TB
        TP1["🧵 Thread Pool<br/>T1 T2 T3 T4 T5"]
        CP1["🔗 Connection Pool<br/>to Shard A"]
    end

    subgraph SERVER_2 ["🖥️ API Server 2 (Process)"]
        direction TB
        TP2["🧵 Thread Pool<br/>T1 T2 T3 T4 T5"]
        CP2["🔗 Connection Pool<br/>to Shard B"]
    end

    subgraph SERVER_3 ["🖥️ API Server 3 (Process)"]
        direction TB
        TP3["🧵 Thread Pool<br/>T1 T2 T3 T4 T5"]
        CP3["🔗 Connection Pool<br/>to Shard C"]
    end

    LB -- "user_id 1-1000" --> SERVER_1
    LB -- "user_id 1001-2000" --> SERVER_2
    LB -- "user_id 2001-3000" --> SERVER_3

    subgraph SHARD_A ["🗄️ Shard A"]
        DB_A_P["Primary<br/><i>Reads + Writes</i>"]
        DB_A_R["Read Replica<br/><i>Reads only</i>"]
        DB_A_P -- "async replication" --> DB_A_R
    end

    subgraph SHARD_B ["🗄️ Shard B"]
        DB_B_P["Primary<br/><i>Reads + Writes</i>"]
        DB_B_R["Read Replica<br/><i>Reads only</i>"]
        DB_B_P -- "async replication" --> DB_B_R
    end

    subgraph SHARD_C ["🗄️ Shard C"]
        DB_C_P["Primary<br/><i>Reads + Writes</i>"]
        DB_C_R["Read Replica<br/><i>Reads only</i>"]
        DB_C_P -- "async replication" --> DB_C_R
    end

    CP1 -- "writes" --> DB_A_P
    CP1 -. "reads" .-> DB_A_R
    CP2 -- "writes" --> DB_B_P
    CP2 -. "reads" .-> DB_B_R
    CP3 -- "writes" --> DB_C_P
    CP3 -. "reads" .-> DB_C_R

Inside a Single API Server Process

Each box labeled “API Server” above is a single OS process. Here’s what’s inside:

graph TB
    subgraph PROCESS ["🖥️ API Server Process (PID: 48291)"]
        direction TB
        
        LISTENER["📡 Main Listener<br/><i>Port 8080</i>"]
        ROUTER["🔀 Router / Dispatcher"]
        MW["🛡️ Middleware Pipeline<br/><i>Auth → Logging → Rate Limit → Deadline Check</i>"]
        
        subgraph HEAP ["Heap Memory (Shared)"]
            CONFIG["📋 App Config<br/><i>static, immutable</i>"]
            CACHE["⚡ Local Cache<br/><i>ConcurrentHashMap</i>"]
            SHARD_MAP["🗺️ Shard Router<br/><i>Consistent Hash Ring</i>"]
        end

        subgraph THREAD_POOL ["🧵 Thread Pool (200 threads)"]
            T1["Thread 1<br/><i>Stack: local vars</i>"]
            T2["Thread 2<br/><i>Stack: local vars</i>"]
            T3["Thread 3<br/><i>Stack: local vars</i>"]
            TN["Thread ..N<br/><i>Stack: local vars</i>"]
        end

        subgraph CONN_POOL ["🔗 DB Connection Pool (20 conns)"]
            DC1["Conn 1"] 
            DC2["Conn 2"]
            DCN["Conn ..N"]
        end

        LISTENER --> ROUTER
        ROUTER --> MW
        MW --> THREAD_POOL
        T1 --> CONN_POOL
        T2 --> CONN_POOL
        T3 --> CONN_POOL
    end

[!NOTE] Each thread gets its own stack (local variables, function call chain) but they all share the heap (config, cache, shard map, connection pool). This is why static mutable state is dangerous — exactly what we discussed earlier.

Consistent Hashing — How the Load Balancer Routes

Instead of simple round-robin (which would send any user to any server), consistent hashing maps each user to a specific server deterministically:

graph LR
    subgraph RING ["Consistent Hash Ring (0 — 2³²)"]
        direction LR
        P0["Position 0"] --- S1_POS["🖥️ Server 1<br/>hash = 1000"]
        S1_POS --- P1["..."]
        P1 --- S2_POS["🖥️ Server 2<br/>hash = 15000"]
        S2_POS --- P2["..."]
        P2 --- S3_POS["🖥️ Server 3<br/>hash = 38000"]
        S3_POS --- P3["..."]
        P3 --- P0
    end
hash("user_42")   = 3500   → lands between Server 1 (1000) and Server 2 (15000)
                            → routes to Server 2 (next server clockwise)

hash("user_99")   = 40000  → lands between Server 3 (38000) and Server 1 (1000)  
                            → routes to Server 1 (wraps around)

hash("user_1337") = 12000  → lands between Server 1 (1000) and Server 2 (15000)
                            → routes to Server 2

Why consistent hashing over simple modulo?

Modulo (user_id % N)Consistent Hashing
Add a serverAlmost ALL users get remapped to different serversOnly ~1/N users get remapped
Remove a serverSame — mass remappingOnly the dead server’s users move to the next one
Cache localityDestroyed on scaling eventsMostly preserved

This is critical because if you’re doing sticky sessions or local caching, a mass remapping means every cache goes cold simultaneously — instant load spike on your databases.

Sharding vs Read Replicas — They Solve Different Problems

You’re right that with sharding you don’t necessarily need replicas, but in practice most systems use both:

graph TB
    subgraph SHARDING ["Sharding — Splits DATA horizontally"]
        SA["Shard A<br/>users 1-1M"]
        SB["Shard B<br/>users 1M-2M"]
        SC["Shard C<br/>users 2M-3M"]
    end
    
    subgraph REPLICATION ["Replication — Copies ENTIRE shard"]
        P["Primary<br/><i>all users 1-1M</i>"]
        R1["Replica 1<br/><i>all users 1-1M</i>"]
        R2["Replica 2<br/><i>all users 1-1M</i>"]
        P -- "sync/async<br/>replication" --> R1
        P -- "sync/async<br/>replication" --> R2
    end
ShardingRead Replicas
Problem solvedDataset too large for one machineToo many reads for one machine
Data on each nodePartial (subset of rows)Full copy (all rows)
Writes go toThe correct shard onlyPrimary only
Reads go toThe correct shard onlyAny replica
Failure impactLose access to that shard’s dataPromote replica to primary, no data loss

When you combine both

Each shard gets its own primary + replicas:

  • Writes → routed by consistent hash to the correct shard’s primary
  • Reads → routed to that shard’s replica (offloading the primary)
  • Failover → if a primary dies, its replica gets promoted

This is what you see at scale — sharding for horizontal data distribution, replicas for read scaling and fault tolerance. They’re complementary, not alternatives.

Complete Request Flow at Scale

sequenceDiagram
    participant C as 👤 Client
    participant LB as ⚖️ Load Balancer
    participant S as 🖥️ API Server 2
    participant T as 🧵 Thread 47
    participant CP as 🔗 Conn Pool
    participant R as 📖 Read Replica
    participant P as ✏️ Primary DB

    C->>LB: GET /api/users/1500
    Note over LB: hash("user_1500") → Server 2

    LB->>S: Forward request
    Note over S: Deadline check: 500ms SLA, clock starts

    S->>T: Assign from thread pool
    activate T

    Note over T: GET request → read-only → use replica

    T->>CP: Acquire connection (to replica)
    CP-->>T: Connection #7

    T->>R: SELECT * FROM users WHERE id=1500
    R-->>T: { id: 1500, name: "Jyotimoy" }

    T->>CP: Release connection #7
    T-->>S: Response ready
    deactivate T

    S-->>LB: 200 OK
    LB-->>C: 200 OK { "name": "Jyotimoy" }

    Note over C, P: Total: 12ms ✅ well within 500ms SLA

The Recursive Insight

Your observation is exactly right:

At scale, the system is just:

    ┌──────────────────────────────────────────────┐
    │           Load Balancer + Routing             │
    └──────┬──────────┬──────────────┬──────────────┘
           │          │              │
    ┌──────▼───┐ ┌────▼─────┐ ┌─────▼────┐
    │ Server 1 │ │ Server 2 │ │ Server 3 │    ← Each one is the
    │ + Shard A│ │ + Shard B│ │ + Shard C│      SAME simple setup
    └──────────┘ └──────────┘ └──────────┘      we started with

Each pair is independently the thread pool + connection pool + single database model from our first diagram. The load balancer + consistent hashing is just the layer that distributes traffic so each pair only handles its slice.

That’s the beauty of good system design — it’s fractal. Zoom in on any piece and it looks like the whole.