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.
Bind & Listen
socket() → bind(:80) → listen()
Accept Connection
TCP handshake completes, fd returned
TLS Handshake
If HTTPS — negotiate cipher, exchange certs
Read Request
Parse method, path, headers, body
Route & Handle
Static file? Proxy to backend? Redirect?
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.
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.
| Behavior | HTTP/1.0 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|---|
| Connection per request | Yes (default) | No (keep-alive) | No (multiplexed) | No (multiplexed) |
| Requests per connection | 1 | Sequential (many) | Concurrent (many) | Concurrent (many) |
| Head-of-line blocking | N/A | Yes (response ordering) | Yes (TCP level) | No (stream independent) |
| Connection setup cost | TCP + TLS each time | TCP + TLS once | TCP + TLS once | QUIC 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:
| Resource | Limit | Tuning |
|---|---|---|
| File descriptors | Default 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 table | Default 65536 entries | net.netfilter.nf_conntrack_max |
| Listen backlog | Default 128-4096 | net.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
eventMPM: event-driven for keep-alive connections, threads for request processing - Best of both worlds — async for I/O, threads for computation
// 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.
// 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.
Master Process
Binds ports, manages workers, reads config, handles signals
Worker 1
Event loop + epoll. Handles connections independently.
Worker 2
Event loop + epoll. Handles connections independently.
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
# /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.
Why Not Expose the App Server Directly?
| Concern | Web Server Handles It | App Server Alone |
|---|---|---|
| TLS termination | Offloads crypto, manages certificates | Must implement TLS, manage certs in app code |
| Slow clients | Buffers requests/responses — protects backend from slow connections | Worker thread held hostage by slow client draining a response at 56 Kbps |
| Static files | Serves from disk with sendfile() — zero-copy, extremely fast | Reads file into app memory, writes to socket — wastes CPU |
| Connection management | Handles 10K+ keep-alive connections efficiently | Limited worker pool (e.g., gunicorn defaults to 2-4 workers) |
| Load balancing | Distributes across multiple backends | Single point |
| Graceful deploys | Drains connections during reload, zero downtime | Restart drops active connections |
| HTTP/2, HTTP/3 | Speaks HTTP/2 to clients, HTTP/1.1 to backends — backends don't need to implement it | Must 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.
# 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
| Algorithm | How It Works | When To Use |
|---|---|---|
| Round-robin | Requests go to backends in order: A, B, C, A, B, C… | Default. Works when backends are identical. |
| Weighted round-robin | Higher-weight backends get proportionally more requests | Backends with different capacities (bigger VM gets more traffic) |
| Least connections | Send to the backend with the fewest active connections | Requests have variable processing time |
| IP hash | Hash the client IP → always goes to the same backend | Sticky sessions without cookies (careful: breaks behind CGNAT) |
| Random with two choices | Pick 2 backends at random, send to the one with fewer connections | Surprisingly effective — avoids the "herd" problem of least-connections |
| Consistent hashing | Hash a key (URL, header) to a ring of backends | Caching 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
- Client connects to the server's IP on port 443
- In the TLS ClientHello, the client includes SNI (Server Name Indication):
example.com - The server selects the correct certificate and completes the TLS handshake
- The HTTP request includes
Host: example.com— the server matches this to aserverblock - The matched server block determines how to handle the request (serve files, proxy, redirect)
# 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
| Pattern | What It Does | Example |
|---|---|---|
| Static files | Serve files from a directory | /static/ → /var/www/static/ |
| Reverse proxy | Forward to a backend service | /api/ → http://backend:8000 |
| Redirect | Send 301/302 to a different URL | http:// → https:// |
| Rewrite | Internally change the URI before handling | /old-path → /new-path |
| Return | Immediately return a status code | Health check: /healthz → 200 OK |
| Rate limit | Limit requests per client/path | API: 100 req/min per IP |
| Auth proxy | Subrequest to auth service before proxying | Check 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.
# 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# 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)
| Version | Negotiation | Server Requirement |
|---|---|---|
| HTTP/1.1 | Default — text-based request on TCP connection | Always supported |
| HTTP/2 | ALPN during TLS handshake — client offers h2, server agrees | TLS required (in practice). listen 443 ssl http2; in nginx |
| HTTP/3 | Client learns about it via Alt-Svc header in HTTP/2 response, then connects on QUIC/UDP | QUIC 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
curlortcpdumpthe backend directly
# 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.
Request arrives
GET /api/products
Cache lookup
Key = method + URI + Vary headers
HIT
Return cached response immediately
MISS
Proxy to backend, cache response, return
| Cache Directive | Meaning | Example |
|---|---|---|
Cache-Control: public, max-age=3600 | Anyone can cache, valid for 1 hour | Static assets, public API responses |
Cache-Control: private, no-cache | Only browser can cache, must revalidate every time | User-specific pages |
Cache-Control: no-store | Never cache | Authentication tokens, sensitive data |
ETag + If-None-Match | Conditional request — return 304 if unchanged | API responses that change infrequently |
Vary: Accept-Encoding | Cache separately for different Accept-Encoding values | gzip 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.
| Header | Purpose | Example Value |
|---|---|---|
X-Forwarded-For | Client's real IP (may be a chain if multiple proxies) | 203.0.113.50, 10.0.0.1 |
X-Forwarded-Proto | Original protocol (https) — backend sees http | https |
X-Forwarded-Host | Original Host header | example.com |
X-Real-IP | Client IP (single, set by first proxy) | 203.0.113.50 |
Forwarded | Standardized 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
# 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:
# 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 Field | What It Measures |
|---|---|
request_time | Total time from first byte received to last byte sent to client |
upstream_connect_time | Time to establish TCP connection to backend |
upstream_header_time | Time until first byte of response from backend |
upstream_response_time | Time 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.
| Header | Purpose | Recommended Value |
|---|---|---|
Strict-Transport-Security | Force HTTPS for future visits (HSTS) | max-age=31536000; includeSubDomains; preload |
X-Content-Type-Options | Prevent MIME-type sniffing | nosniff |
X-Frame-Options | Prevent clickjacking (iframe embedding) | DENY or SAMEORIGIN |
Content-Security-Policy | Control which resources can load (XSS mitigation) | Depends on app — start with default-src 'self' |
Referrer-Policy | Control how much referrer info is sent | strict-origin-when-cross-origin |
Permissions-Policy | Disable browser features (camera, mic, geolocation) | camera=(), microphone=(), geolocation=() |
# 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.