Skip to content
Menu

Networking16 min read

Web Servers

What they do, how they handle connections, and how they fit in the stack

What a Web Server Actually Does

A web server is a process that listens on a TCP socket (typically port 80/443), accepts incoming connections, parses HTTP requests, and sends back HTTP responses. That's the core loop — everything else is configuration around it.

  1. Bind & Listen

    socket() → bind(:80) → listen()

  2. Accept Connection

    TCP handshake completes, fd returned

  3. TLS Handshake

    If HTTPS — negotiate cipher, exchange certs

  4. Read Request

    Parse method, path, headers, body

  5. Route & Handle

    Static file? Proxy to backend? Redirect?

  6. Send Response

    Status line, headers, body bytes

The Three Core Jobs

Serve Static Files

Read files from disk (HTML, CSS, JS, images) and send them over HTTP. The server maps the URL path to a filesystem path, sets Content-Type from the file extension, and streams the bytes. This is what web servers were originally built for.

Reverse Proxy

Forward requests to backend application servers (Python/Go/Node/Java) and relay the response back to the client. The web server handles TLS, HTTP parsing, connection management, and load balancing — the app server just processes business logic.

Terminate TLS

Handle the TLS handshake, certificate management, and encryption/decryption so that backend services don't have to. Internal traffic between the web server and backends often runs over plain HTTP on a trusted network.

Connections — What They Are and How They're Managed

An HTTP connection is a TCP connection (or QUIC session for HTTP/3) between the client and the server. Understanding connections is fundamental because they determine performance, resource usage, and concurrency.

Anatomy of a Connection

Every connection is identified by the 5-tuple: (protocol, client IP, client port, server IP, server port). Each connection is a file descriptor in the server process — it consumes memory (kernel buffers, TLS state, application buffers) and a slot in the OS's fd table.

Client
Web Server
SYN
SYN-ACK
ACK (connection open)
ClientHello (TLS)
ServerHello + Cert + Finished
Finished (TLS complete)
GET /index.html HTTP/1.1
HTTP/1.1 200 OK + body
GET /style.css HTTP/1.1
HTTP/1.1 200 OK + body

Note

Cost of a new connection: TCP handshake (1 RTT) + TLS 1.3 handshake (1 RTT) = 2 RTTs minimum before the first byte of HTTP data. On a 50ms RTT link, that's 100ms of pure latency before anything useful happens. This is why connection reuse matters so much.

Keep-Alive and Connection Reuse

In HTTP/1.1, connections are persistent by default (Connection: keep-alive). After the server sends a response, the connection stays open for the next request. The server closes it after a configurable idle timeout.

BehaviorHTTP/1.0HTTP/1.1HTTP/2HTTP/3
Connection per requestYes (default)No (keep-alive)No (multiplexed)No (multiplexed)
Requests per connection1Sequential (many)Concurrent (many)Concurrent (many)
Head-of-line blockingN/AYes (response ordering)Yes (TCP level)No (stream independent)
Connection setup costTCP + TLS each timeTCP + TLS onceTCP + TLS onceQUIC 1-RTT (or 0-RTT)

Connection Limits and Concurrency

A web server can handle thousands to millions of concurrent connections, depending on its architecture. Key limits:

ResourceLimitTuning
File descriptorsDefault 1024 per process (soft limit)ulimit -n 65535 or /etc/security/limits.conf
Ephemeral ports~28K per destination IP (client-side)net.ipv4.ip_local_port_range
Memory per connection~10-50 KB (kernel buffers + TLS state)net.ipv4.tcp_rmem / tcp_wmem
Conntrack tableDefault 65536 entriesnet.netfilter.nf_conntrack_max
Listen backlogDefault 128-4096net.core.somaxconn

Tip

The C10K problem: In the early 2000s, handling 10,000 concurrent connections was a major engineering challenge. The solution was moving from thread-per-connection to event-driven I/O (epoll/kqueue). Today, a single nginx worker can handle 10K+ connections. The modern challenge is C10M (10 million).

Connection Handling Models

The fundamental question for any server: how do you handle many connections at once? The answer has evolved over decades.

1. Process-per-Connection (Fork)

How it works

For each incoming connection, fork() a new child process. The child handles the entire request lifecycle and exits. The parent process just accepts and forks.

  • Isolation: one connection crashing can't take down others
  • Simple: no concurrency bugs — each process has its own memory
  • Expensive: a process is ~1-10 MB of memory, context switching is costly
  • Limit: hundreds to low thousands of concurrent connections

This is Apache's prefork MPM. Also how old-school CGI worked.

2. Thread-per-Connection

How it works

Instead of forking a process, spawn a thread. Threads share the process's address space, so they're lighter (~8 KB stack default on Linux vs ~MB for a process).

  • Lighter: threads are cheaper to create and switch between
  • Shared state: threads share memory — need synchronization (mutexes, etc.)
  • Limit: thousands of connections (thread stacks and scheduling overhead)

This is Apache's worker MPM and how many Java/Go servers work (though Go uses goroutines — green threads multiplexed onto OS threads).

3. Event-Driven / Non-Blocking I/O

How it works

A single thread uses an event loop and the OS's I/O multiplexing API (epoll on Linux, kqueue on macOS/BSD) to monitor thousands of sockets simultaneously. When a socket is ready for reading or writing, the event loop dispatches the work.

  • Extremely efficient: one thread handles tens of thousands of connections
  • No thread overhead: no thread stacks, no context switches, no locks
  • Complexity: callback/state-machine programming — can't do blocking operations
  • CPU-bound work blocks the loop: need to offload to worker threads

This is nginx's architecture and the model behind Node.js, HAProxy, and Traefik. It's why nginx can handle 10K+ connections per worker with minimal memory.

4. Hybrid: Event Loop + Worker Threads

How it works

Combine event-driven I/O for network operations with a thread pool for blocking tasks (disk I/O, DNS lookups, upstream connections). The event loop dispatches I/O-ready events; worker threads handle slow operations.

  • nginx uses this: main event loop + thread pool for aio threads
  • Apache's event MPM: event-driven for keep-alive connections, threads for request processing
  • Best of both worlds — async for I/O, threads for computation
Thread-per-Connection
bash
// Pseudocode
while (true) {
  conn = accept(listen_fd)
  spawn_thread(() => {
    request = read(conn)    // blocks this thread
    response = handle(request)
    write(conn, response)  // blocks this thread
    close(conn)
  })
}

1 thread per connection. 10K connections = 10K threads = ~80 MB in stacks alone.

Event-Driven
bash
// Pseudocode
epoll_fd = epoll_create()
epoll_add(epoll_fd, listen_fd)

while (true) {
  events = epoll_wait(epoll_fd) // blocks until ANY fd ready
  for (ev in events) {
    if (ev.fd == listen_fd)
      accept_and_register(ev.fd)
    else
      handle_io(ev.fd) // non-blocking read/write
  }
}

1 thread for all connections. 10K connections ≈ 10K fd entries in epoll. Minimal memory.

Worker Architecture in Practice

Modern web servers don't run a single event loop — they run multiple worker processes, each with its own event loop, to use all CPU cores. The master process manages workers, handles config reloads, and binds to privileged ports.

  1. Master Process

    Binds ports, manages workers, reads config, handles signals

  2. Worker 1

    Event loop + epoll. Handles connections independently.

  3. Worker 2

    Event loop + epoll. Handles connections independently.

  4. Worker N

    Event loop + epoll. One per CPU core (typically).

All workers share the same listening socket(s). The kernel distributes incoming connections across workers via SO_REUSEPORT (since Linux 3.9) or the accept mutex.

Note

Graceful reload: On SIGHUP (or equivalent), the master spawns new workers with the new config and tells old workers to drain existing connections and exit. No dropped connections — this is how zero-downtime config reloads work.

nginx Example

console
# /etc/nginx/nginx.conf
worker_processes auto;          # one per CPU core
worker_connections 4096;       # max connections per worker
                                    # total capacity = workers × worker_connections

events {
    use epoll;                   # Linux event multiplexer
    multi_accept on;             # accept multiple connections at once
}

http {
    keepalive_timeout 65;        # close idle connections after 65s
    keepalive_requests 1000;    # max requests per keep-alive connection

    server {
        listen 443 ssl http2;
        server_name example.com;

        ssl_certificate     /etc/ssl/certs/example.pem;
        ssl_certificate_key /etc/ssl/private/example.key;

        # Serve static files directly
        location /static/ {
            root /var/www;
            expires 30d;
            add_header Cache-Control "public, immutable";
        }

        # Reverse proxy to application server
        location /api/ {
            proxy_pass http://backend:8000;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}

Reverse Proxy — The Most Important Role

In modern deployments, the web server's primary role isn't serving files — it's acting as a reverse proxy in front of application servers. The web server faces the internet; the app server stays internal.

Internet
Clients (browsers, APIs)
Web Server
TLS termination, routing, caching
App Server(s)
gunicorn, uvicorn, Node, Go binary
Database
PostgreSQL, Redis

Why Not Expose the App Server Directly?

ConcernWeb Server Handles ItApp Server Alone
TLS terminationOffloads crypto, manages certificatesMust implement TLS, manage certs in app code
Slow clientsBuffers requests/responses — protects backend from slow connectionsWorker thread held hostage by slow client draining a response at 56 Kbps
Static filesServes from disk with sendfile() — zero-copy, extremely fastReads file into app memory, writes to socket — wastes CPU
Connection managementHandles 10K+ keep-alive connections efficientlyLimited worker pool (e.g., gunicorn defaults to 2-4 workers)
Load balancingDistributes across multiple backendsSingle point
Graceful deploysDrains connections during reload, zero downtimeRestart drops active connections
HTTP/2, HTTP/3Speaks HTTP/2 to clients, HTTP/1.1 to backends — backends don't need to implement itMust implement newer protocols

Warning

Slow client buffering is one of the most critical reasons. A Python/Ruby/Node app server typically has a small fixed number of workers (4-16). If a slow client takes 30 seconds to download a response, that worker is occupied for 30 seconds. With a reverse proxy, the web server buffers the full response from the backend instantly (local network, fast), frees the backend worker, and slowly drains to the client using its event loop. The backend worker is free to handle another request in milliseconds.

Upstream Connection Pooling

The web server maintains a pool of persistent connections to each backend (upstream). Instead of opening a new TCP connection for every proxied request, it reuses existing ones — eliminating the TCP + TLS handshake overhead on the backend path.

console
# nginx upstream with connection pooling
upstream backend {
    server 10.0.1.10:8000;
    server 10.0.1.11:8000;
    server 10.0.1.12:8000;
    keepalive 32;              # keep 32 idle connections per worker
}

server {
    location /api/ {
        proxy_pass http://backend;
        proxy_http_version 1.1;                # required for keepalive to upstreams
        proxy_set_header Connection "";        # clear Connection: close
    }
}

Load Balancing

When proxying to multiple backends, the web server distributes requests across them. This is L7 load balancing — the server understands HTTP and can make routing decisions based on URL path, headers, cookies, etc.

Load Balancing Algorithms

AlgorithmHow It WorksWhen To Use
Round-robinRequests go to backends in order: A, B, C, A, B, C…Default. Works when backends are identical.
Weighted round-robinHigher-weight backends get proportionally more requestsBackends with different capacities (bigger VM gets more traffic)
Least connectionsSend to the backend with the fewest active connectionsRequests have variable processing time
IP hashHash the client IP → always goes to the same backendSticky sessions without cookies (careful: breaks behind CGNAT)
Random with two choicesPick 2 backends at random, send to the one with fewer connectionsSurprisingly effective — avoids the "herd" problem of least-connections
Consistent hashingHash a key (URL, header) to a ring of backendsCaching layers — same URL always hits same backend, maximizing cache hits

Note

L4 vs L7 load balancing: An L4 load balancer (IPVS, AWS NLB, kube-proxy) operates at the TCP level — it forwards connections without understanding HTTP. An L7 load balancer (nginx, Traefik, Envoy, AWS ALB) terminates the HTTP connection and makes routing decisions based on HTTP content. L7 is more flexible but more expensive (must parse HTTP).

Virtual Hosting — Multiple Sites on One Server

A single web server on one IP can serve different websites based on the Host header (HTTP/1.1) or SNI (TLS). This is how shared hosting works, and how a reverse proxy routes to different backends per domain.

How It Works

  1. Client connects to the server's IP on port 443
  2. In the TLS ClientHello, the client includes SNI (Server Name Indication): example.com
  3. The server selects the correct certificate and completes the TLS handshake
  4. The HTTP request includes Host: example.com — the server matches this to a server block
  5. The matched server block determines how to handle the request (serve files, proxy, redirect)
console
# nginx: two sites on the same IP
server {
    listen 443 ssl;
    server_name app.example.com;
    ssl_certificate /etc/ssl/app.pem;
    location / { proxy_pass http://app-backend:3000; }
}

server {
    listen 443 ssl;
    server_name api.example.com;
    ssl_certificate /etc/ssl/api.pem;
    location / { proxy_pass http://api-backend:8000; }
}

Request Routing

Once the server block is matched (by Host/server_name), the server evaluates the request URI against location blocks to decide how to handle it. This is the core routing logic.

Common Routing Patterns

PatternWhat It DoesExample
Static filesServe files from a directory/static//var/www/static/
Reverse proxyForward to a backend service/api/http://backend:8000
RedirectSend 301/302 to a different URLhttp://https://
RewriteInternally change the URI before handling/old-path/new-path
ReturnImmediately return a status codeHealth check: /healthz → 200 OK
Rate limitLimit requests per client/pathAPI: 100 req/min per IP
Auth proxySubrequest to auth service before proxyingCheck JWT with auth service

Traefik Example — Dynamic, Label-Based Routing

Traefik takes a different approach from nginx: instead of static config files, it discovers routes dynamically from infrastructure (Docker labels, K8s Ingress, Consul). The architecture is based on entrypoints, routers, middlewares, and services.

console
# traefik.yml — static configuration
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
  websecure:
    address: ":443"

providers:
  docker:
    exposedByDefault: false        # only route explicitly labeled containers

certificatesResolvers:
  letsencrypt:
    acme:
      email: admin@example.com
      storage: /data/acme.json
      httpChallenge:
        entryPoint: web
console
# docker-compose.yml — routes discovered from labels
services:
  app:
    image: myapp:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`app.example.com`)"
      - "traefik.http.routers.app.tls.certresolver=letsencrypt"
      - "traefik.http.services.app.loadbalancer.server.port=3000"

  api:
    image: myapi:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.api.rule=Host(`api.example.com`)"
      - "traefik.http.routers.api.tls.certresolver=letsencrypt"
      - "traefik.http.routers.api.middlewares=ratelimit"
      - "traefik.http.middlewares.ratelimit.ratelimit.average=100"
      - "traefik.http.services.api.loadbalancer.server.port=8000"

Tip

Static vs dynamic config: nginx reads config files and requires a reload (nginx -s reload) to pick up changes. Traefik watches its providers (Docker, K8s, file) and updates routes in real-time — no reload, no downtime. Both approaches work; the choice depends on how dynamic your infrastructure is.

HTTP Version Handling

A web server must negotiate which HTTP version to use with the client, and separately which version to use with backends. These are often different.

Client-Facing (Frontend)

VersionNegotiationServer Requirement
HTTP/1.1Default — text-based request on TCP connectionAlways supported
HTTP/2ALPN during TLS handshake — client offers h2, server agreesTLS required (in practice). listen 443 ssl http2; in nginx
HTTP/3Client learns about it via Alt-Svc header in HTTP/2 response, then connects on QUIC/UDPQUIC listener on UDP 443. listen 443 quic; in nginx (1.25+)

Note

HTTP/3 discovery: The first request always uses HTTP/1.1 or HTTP/2 over TCP. The server's response includes Alt-Svc: h3=":443"; ma=86400. The client then opens a QUIC connection for subsequent requests. This means you always need TCP 443 open alongside UDP 443.

Backend-Facing (Upstream)

The web server typically speaks HTTP/1.1 to backends, even if it speaks HTTP/2 or HTTP/3 to clients. Reasons:

  • App servers (gunicorn, uvicorn, Node) already handle HTTP/1.1 well
  • HTTP/2's multiplexing benefit is on the internet (high latency) — on a local network with sub-millisecond RTT, there's little advantage
  • Connection pooling with HTTP/1.1 keepalive is sufficient for backend traffic
  • Simpler to debug — you can curl or tcpdump the backend directly
console
# nginx: HTTP/2 to clients, HTTP/1.1 to backends
server {
    listen 443 ssl http2;     # client-facing: HTTP/2
    listen 443 quic;           # client-facing: HTTP/3
    add_header Alt-Svc 'h3=":443"; ma=86400';  # advertise HTTP/3

    location / {
        proxy_pass http://backend:8000;     # backend: plain HTTP/1.1
        proxy_http_version 1.1;              # keepalive requires 1.1
    }
}

Response Caching

The web server can cache backend responses and serve them directly for subsequent requests, avoiding a round-trip to the app server entirely.

  1. Request arrives

    GET /api/products

  2. Cache lookup

    Key = method + URI + Vary headers

  3. HIT

    Return cached response immediately

  4. MISS

    Proxy to backend, cache response, return

Cache DirectiveMeaningExample
Cache-Control: public, max-age=3600Anyone can cache, valid for 1 hourStatic assets, public API responses
Cache-Control: private, no-cacheOnly browser can cache, must revalidate every timeUser-specific pages
Cache-Control: no-storeNever cacheAuthentication tokens, sensitive data
ETag + If-None-MatchConditional request — return 304 if unchangedAPI responses that change infrequently
Vary: Accept-EncodingCache separately for different Accept-Encoding valuesgzip vs brotli compressed versions

Headers the Web Server Adds

When reverse proxying, the web server should pass along client information that the backend needs. These headers are not part of the original client request — the proxy adds them.

HeaderPurposeExample Value
X-Forwarded-ForClient's real IP (may be a chain if multiple proxies)203.0.113.50, 10.0.0.1
X-Forwarded-ProtoOriginal protocol (https) — backend sees httphttps
X-Forwarded-HostOriginal Host headerexample.com
X-Real-IPClient IP (single, set by first proxy)203.0.113.50
ForwardedStandardized version (RFC 7239) — replaces X-Forwarded-*for=203.0.113.50;proto=https;host=example.com

Warning

Security: trust boundaries. X-Forwarded-For can be spoofed by the client. The web server should overwrite (not append to) these headers unless it trusts the upstream proxy. In nginx: proxy_set_header X-Real-IP $remote_addr; sets it to the direct connection's IP, which can't be spoofed.

Access Logging

Every request processed by the web server can be logged. These access logs are the primary source of truth for traffic analysis, debugging, and security monitoring.

Common Log Fields

console
# nginx combined log format
log_format combined
    '$remote_addr - $remote_user [$time_local] '
    '"$request" $status $body_bytes_sent '
    '"$http_referer" "$http_user_agent"';

# Example output:
203.0.113.50 - - [31/Mar/2026:14:23:01 +0000] "GET /api/users HTTP/2.0" 200 1234 "-" "curl/8.5.0"

For observability, add timing fields:

console
# nginx with upstream timing
log_format timed
    '$remote_addr [$time_local] "$request" $status '
    'rt=$request_time uct=$upstream_connect_time '
    'uht=$upstream_header_time urt=$upstream_response_time';

# Example:
203.0.113.50 [31/Mar/2026:14:23:01] "GET /api/users HTTP/2.0" 200 rt=0.045 uct=0.001 uht=0.040 urt=0.040
Timing FieldWhat It Measures
request_timeTotal time from first byte received to last byte sent to client
upstream_connect_timeTime to establish TCP connection to backend
upstream_header_timeTime until first byte of response from backend
upstream_response_timeTime until last byte of response from backend

Tip

Debugging slow requests: If request_time is high but upstream_response_time is low, the bottleneck is the client connection (slow download). If upstream_response_time is high, the backend is slow. If upstream_connect_time is high, the backend can't accept connections fast enough (overloaded, listen backlog full).

Security Headers

The web server is the right place to enforce security headers — they apply globally and don't depend on the backend implementing them.

HeaderPurposeRecommended Value
Strict-Transport-SecurityForce HTTPS for future visits (HSTS)max-age=31536000; includeSubDomains; preload
X-Content-Type-OptionsPrevent MIME-type sniffingnosniff
X-Frame-OptionsPrevent clickjacking (iframe embedding)DENY or SAMEORIGIN
Content-Security-PolicyControl which resources can load (XSS mitigation)Depends on app — start with default-src 'self'
Referrer-PolicyControl how much referrer info is sentstrict-origin-when-cross-origin
Permissions-PolicyDisable browser features (camera, mic, geolocation)camera=(), microphone=(), geolocation=()
console
# nginx: add security headers globally
server {
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "DENY" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}

Where the Web Server Fits

Zooming out — the web server sits at a specific point in the request lifecycle, and different deployment models place it differently.

Traditional: Dedicated Reverse Proxy

Note

Client → nginx/Traefik → gunicorn/uvicorn → Python app

The web server is a separate process (or container) running on the same host or a dedicated proxy tier. This is the most common production pattern.

Kubernetes: Ingress Controller

Note

Client → Ingress Controller (nginx/Traefik pod) → ClusterIP Service → App Pod

The Ingress controller IS a web server deployed as a K8s pod. It reads Ingress/HTTPRoute resources and configures its routing rules dynamically. See Net 13 — Ingress & Gateway API.

CDN Edge: Cloud-Managed

Note

Client → CDN edge (Cloudflare, AWS CloudFront) → Origin server

The CDN acts as a global web server — TLS termination, caching, DDoS protection, HTTP/3 — at points of presence worldwide. Your origin server may still run nginx, but it only sees traffic from the CDN.

Sidecar Proxy: Service Mesh

Note

Client Pod → Envoy sidecar → Network → Envoy sidecar → App Pod

In a service mesh (Istio, Linkerd), every pod gets a sidecar proxy that handles mTLS, retries, circuit breaking, and observability. The sidecar IS a web server (Envoy) — just one per pod instead of one per cluster. See Net 13 — Network Policies.

Solidnines — solidnines.com