System Design ·

How Nginx Works: Event-Driven Architecture, Request Processing, and Reverse Proxy Mechanics

A deep dive into Nginx internals for senior engineers. Covers the master-worker process model, epoll/kqueue event loops, the request processing pipeline, reverse proxy upstream management with keepalive pools, load balancing algorithms, SSL termination, sendfile for static assets, and connection draining during graceful reload. Includes a tradeoffs table comparing Nginx, Caddy, HAProxy, and Envoy.

How Nginx Works: Event-Driven Architecture, Request Processing, and Reverse Proxy Mechanics

Most engineers who configure Nginx daily have never looked past the configuration syntax. You know proxy_pass, location blocks, and upstream groups. But the interesting part is what happens between a TCP SYN and a proxied response: a non-blocking event loop, a phased request pipeline, and connection pools that can drastically change how your upstream behaves under load.

This article covers the internals in enough depth that you can reason about Nginx behavior under pressure, not just copy-paste configuration snippets.

The Master-Worker Process Model

Nginx starts as a single master process, then forks one worker per CPU core (configured via worker_processes auto). The master process owns the listening sockets and handles signals. Worker processes do all the actual work: accepting connections, reading requests, proxying to upstreams, and writing responses.

This split is not an accident. The master holds the bound socket file descriptors. During a reload (nginx -s reload), the master forks new worker processes, which inherit the sockets and start accepting new connections immediately. Old workers continue draining their in-flight connections. Once they finish, they exit. Zero downtime, no dropped connections.

Worker processes are single-threaded. They do not share state through shared memory except for a handful of atomic counters (e.g., connection counts in ngx_accept_mutex for limiting concurrent accept storms). Each worker runs its own event loop.

worker_processes auto;
worker_rlimit_nofile 65535;

events {
  worker_connections 4096;
  use epoll;         # Linux; kqueue on BSD/macOS
  multi_accept on;   # accept as many connections as possible per wakeup
}

The worker_connections limit is per-worker. Total theoretical concurrent connections is worker_processes * worker_connections. In practice, upstream connections count against this too: each proxied request consumes two file descriptors (client-side and upstream-side), so the real concurrent request capacity is roughly half that number.

The Event Loop: epoll and kqueue

Each worker runs a tight event loop. On Linux, Nginx uses epoll. On BSD and macOS, it uses kqueue. Both are edge-triggered notification APIs: the kernel tells the process when a file descriptor transitions from not-ready to ready, rather than re-scanning every descriptor on every loop iteration.

The core loop:

  1. Call epoll_wait with a timeout (usually 500ms as a fallback for timer resolution).
  2. For each ready event, invoke the registered handler.
  3. Run expired timers.
  4. Repeat.

No threads, no blocking calls. If a handler blocks (a synchronous DNS lookup, a blocking read), the entire worker stalls. This is why Nginx uses the system resolver with a short timeout or resolver directives with async DNS; blocking anywhere destroys the concurrency model.

The single-threaded design eliminates lock contention for the request handling path entirely. There is no mutex protecting the connection state because no other thread touches it. This is the same architectural bet Redis makes, and for the same reasons.

For file I/O, Nginx uses sendfile(2) for static assets, which copies directly from the page cache to the socket buffer in kernel space. No userspace copy, no extra context switch. On Linux with large files, it also uses sendfile with TCP corking (tcp_nopush on) to batch the response headers and the first data chunk into a single TCP segment.

http {
  sendfile        on;
  tcp_nopush      on;   # batch headers + first chunk
  tcp_nodelay     on;   # after the file, flush immediately
  keepalive_timeout 65;
}

Request Processing: The Phases Pipeline

An Nginx HTTP request flows through a defined sequence of phases. Modules hook into these phases to inspect or modify the request. The phases, in order:

  1. Post-read: Headers just parsed. Rarely used directly.
  2. Server rewrite: rewrite directives in the server block run here.
  3. Find config: Nginx selects the location block. This is where the longest-prefix and regex matching happens.
  4. Rewrite: rewrite directives in the matched location block.
  5. Post-rewrite: Jump back to find-config if the URI changed.
  6. Preaccess: Rate limiting (limit_req, limit_conn) runs here.
  7. Access: Auth checks (allow/deny, auth_basic, auth_request).
  8. Post-access: Error handling for access denials.
  9. Try-files: try_files directive evaluated.
  10. Content: The content handler runs. This is proxy_pass, fastcgi_pass, return, or the static file module.
  11. Log: Access logging.

This pipeline is the reason deny all in a location block fires before the upstream ever sees the request: access phase runs before content phase, always.

Server Block and Location Selection

When a request arrives, Nginx first picks the server block. It matches in this order: exact server_name, leading wildcard, trailing wildcard, regex, then the default server. The listen port is always the first filter.

Inside the server block, location matching follows a strict precedence:

  1. = (exact match): highest priority, stops search immediately.
  2. ^~ (prefix, no regex): if matched, skip regex locations.
  3. Regex (~ case-sensitive, ~* case-insensitive): first match wins, in file order.
  4. Longest prefix match (no modifier): wins only if no regex matched.
server {
  server_name api.example.com;

  # Exact match: /healthz handled immediately
  location = /healthz {
    return 200 "ok";
  }

  # Prefix + no-regex: /static served from disk, skips regex
  location ^~ /static/ {
    root /var/www;
    expires 30d;
  }

  # Regex: anything ending in .json gets a specific content type
  location ~* \.json$ {
    add_header Content-Type application/json;
    proxy_pass http://api_backend;
  }

  # Default: everything else proxied
  location / {
    proxy_pass http://api_backend;
  }
}

A common gotcha: a longer prefix like /api/v2 does not automatically beat a regex ~* /api/. The regex wins unless you use ^~.

Reverse Proxy and Upstream Management

proxy_pass instructs Nginx to forward the request to an upstream. The mechanics matter more than the directive:

  1. Nginx reads the full client request (headers, body buffer) before opening the upstream connection by default. This shields slow-upload clients from holding upstream workers.
  2. Nginx opens a TCP connection to the upstream (or retrieves one from the keepalive pool).
  3. It forwards the request and buffers the response.
  4. Once the response headers arrive, Nginx begins streaming back to the client.

The upstream keepalive pool is the most important performance knob most engineers never tune:

upstream api_backend {
  server 10.0.1.10:8080;
  server 10.0.1.11:8080;
  keepalive 64;  # idle keepalive connections per worker to this upstream
}

server {
  location / {
    proxy_pass http://api_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";  # required for keepalive to work
  }
}

Without keepalive, every proxied request opens and closes a TCP connection to the upstream. At 1000 req/s across 4 workers, that is 1000 new TCP handshakes per second, each adding 1-2 RTT of latency. With keepalive 64, each worker maintains up to 64 idle connections. The math: if your upstream response time is 20ms and you have 4 workers, each keepalive connection can handle ~50 req/s. You need roughly (rps / workers) * avg_latency_seconds connections to avoid queuing.

The proxy_http_version 1.1 directive is mandatory. HTTP/1.0 (the Nginx default for upstream) sends Connection: close on every request, which destroys keepalive entirely.

Upstream Buffering

By default, Nginx buffers the upstream response before sending to the client. This protects upstream workers from slow clients who read responses at modem speed. The buffer size is proxy_buffer_size (headers) plus proxy_buffers (body). When the body exceeds the buffer, Nginx spills to a temp file (proxy_temp_path).

For streaming responses (SSE, chunked APIs), you want buffering off:

location /stream {
  proxy_pass http://api_backend;
  proxy_buffering off;
  proxy_read_timeout 3600s;
}

Load Balancing Algorithms

Nginx ships four upstream balancing strategies:

Round-robin (default): requests distributed sequentially across servers. Works well when backends are homogeneous.

Least connections (least_conn): sends to the server with the fewest active connections. Better than round-robin when request durations vary significantly.

IP hash (ip_hash): hashes the client IP to pin sessions to a specific upstream. Useful for legacy session affinity. Problem: any change to the server list re-hashes most clients.

Hash (hash $variable): hash any variable (URI, cookie, custom header). More flexible than ip_hash, and with consistent keyword uses consistent hashing to minimize disruption on server list changes.

upstream api_backend {
  least_conn;
  server 10.0.1.10:8080 weight=2;
  server 10.0.1.11:8080 weight=1;
  server 10.0.1.12:8080 backup;
}

weight multiplies the round-robin or least-conn selection probability. backup servers only receive traffic when all non-backup servers are unavailable or at capacity.

Nginx also supports passive health checks via max_fails and fail_timeout: after N failures within the timeout window, the server is marked unavailable. Active health checks (polling /healthz proactively) require Nginx Plus or the ngx_http_upstream_check_module from OpenResty.

SSL Termination

Nginx terminates TLS before the request reaches the proxying logic. Configuration is straightforward; the interesting parts are performance and security:

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

  ssl_certificate     /etc/nginx/ssl/cert.pem;
  ssl_certificate_key /etc/nginx/ssl/key.pem;

  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384;
  ssl_prefer_server_ciphers off;  # TLS 1.3 ignores this; correct for 1.2

  ssl_session_cache   shared:SSL:10m;  # shared across workers
  ssl_session_timeout 1d;
  ssl_session_tickets off;  # tickets are a forward-secrecy concern

  # OCSP stapling reduces TLS handshake RTT
  ssl_stapling on;
  ssl_stapling_verify on;
  resolver 8.8.8.8 valid=300s;
}

ssl_session_cache shared:SSL:10m is the key directive for TLS performance. Without it, each worker has its own session cache, and a client reconnecting to a different worker must do a full handshake. The shared cache means any worker can resume a session. 10 MB holds approximately 40,000 sessions.

HTTP/2 (http2 on the listen directive) uses header compression (HPACK) and multiplexing. Nginx maps each HTTP/2 stream to an internal request object and proxies them independently. The upstream connections are still HTTP/1.1 unless you explicitly configure h2c upstream (not supported in open-source Nginx for proxying; Nginx Plus adds it).

Configuration Inheritance

Nginx uses a block-scoping model for directives. Most directives defined in a parent context are inherited into child contexts, but a directive in a child context completely replaces the parent’s value (not merges with it). This causes a common pitfall with add_header:

http {
  add_header X-Frame-Options SAMEORIGIN;  # set at http level

  server {
    location /api {
      add_header Content-Type application/json;
      # X-Frame-Options is NOT inherited here because this location
      # has its own add_header directive. You must repeat both.
      add_header X-Frame-Options SAMEORIGIN;
    }
  }
}

The same replacement behavior applies to proxy_set_header, gzip_types, and several other list-style directives. The rule of thumb: if a child block has any instance of a directive, the parent’s instances of that directive are invisible in that child.

Graceful Reload and Connection Draining

nginx -s reload sends SIGHUP to the master process. The sequence:

  1. Master re-reads configuration and validates it (a syntax error here aborts the reload with no disruption).
  2. Master forks new worker processes. They inherit the listening sockets and immediately begin accepting.
  3. Master sends SIGQUIT to old workers. They stop accepting new connections but continue serving existing ones.
  4. Old workers exit once their active connections close.

The worker_shutdown_timeout directive sets a hard deadline for old workers to exit, which matters for long-lived WebSocket or SSE connections:

worker_shutdown_timeout 30s;

After 30 seconds, remaining connections in old workers are force-closed. Without this, a misbehaving long-poll client could pin an old worker indefinitely.

This model means you can push a configuration change with zero dropped requests. The only exception is when the listen socket configuration itself changes (e.g., adding a new port), which requires OS-level socket rebinding and may briefly affect new connection acceptance.

Tradeoffs: Nginx vs Caddy vs HAProxy vs Envoy

NginxCaddyHAProxyEnvoy
ArchitectureEvent-driven, multi-processEvent-driven, single binaryEvent-driven, multi-processEvent-driven, multi-thread
Config modelDeclarative, file-basedDeclarative (Caddyfile or JSON API)Declarative, ACL-basedxDS API (dynamic, Protobuf)
TLS automationManual or Certbot integrationBuilt-in ACME, auto-renewsManualManual or via SDS
HTTP/2 upstreamNginx Plus onlyYes (h2c)YesYes
gRPC proxyingLimited (open-source)YesLimitedFirst-class
Load balancingRound-robin, least-conn, ip-hash, consistent hashRound-robin, least-conn, ip-hashRound-robin, least-conn, source, URI, random, stickyRound-robin, least-req, ring hash, Maglev, random
Dynamic config reloadGraceful (SIGHUP)Hot reload via APIGraceful (SIGUSR2)Zero-downtime via xDS
ObservabilityBasic log formats, stub_statusPrometheus metrics built-inStats socket, Prometheus exporterPrometheus, Zipkin, Datadog native
Module ecosystemVery large (OpenResty/Lua)Go pluginsLimitedLarge (WASM filters, C++ filters)
Operational complexityLowVery lowLowHigh
Best fitGeneral-purpose reverse proxy, static servingDeveloper-friendly HTTPS, small teamsL4+L7 load balancing, TCP proxyingService mesh, Kubernetes sidecar

HAProxy tends to win on raw L4 throughput and advanced health checking. Envoy is the right choice when you need dynamic service discovery (Kubernetes, Consul), distributed tracing injection, or WASM-based request filtering. Caddy earns its place when TLS certificate management is the pain, not performance. Nginx covers the broad middle: high-performance static serving, reliable reverse proxying, and a configuration model most teams already know.

Production Considerations

Tune worker_connections against actual file descriptor limits. The OS default is often 1024 per process. Set worker_rlimit_nofile to match or exceed worker_connections * 2 (upstream + client fds).

Set proxy_connect_timeout conservatively. The default is 60 seconds. A slow upstream DNS resolution or unresponsive host will hold up a connection slot for a full minute. proxy_connect_timeout 5s; proxy_read_timeout 30s; is more realistic for API backends.

Use upstream keepalive even for single-server deployments. It is not just for load balancing. A single upstream with keepalive 32 still eliminates TCP handshake overhead on every request.

Avoid proxy_buffering off globally. It shifts the burden of slow clients to upstream workers. Use it selectively on streaming endpoints.

Watch Too many open files errors in logs. They always mean worker_rlimit_nofile is too low, not that you need more workers.

Log $upstream_response_time separately from $request_time. The difference is the time Nginx spent reading the request body and writing the response to the client. If request_time is consistently much larger than upstream_response_time, your clients are slow writers or readers, not your backend.

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                '$status $body_bytes_sent '
                'rt=$request_time uct=$upstream_connect_time uht=$upstream_header_time urt=$upstream_response_time';

Test configuration before reloading. nginx -t validates syntax and checks for file existence errors without touching the running process. Make it a habit before every nginx -s reload.

Closing

Nginx’s performance comes from a design that eliminates what makes most servers slow: thread-per-connection overhead, blocking I/O, and lock contention in the hot path. The event loop, the phased pipeline, and the keepalive pool are not implementation details worth ignoring. They are the mental model you need to diagnose latency spikes, tune connection limits, and explain why a configuration change behaved differently than expected under load.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.