Event-Driven Architecture #
When people say “Nginx is fast,” they’re really talking about an architectural decision Igor Sysoev made two decades ago. Understanding this architecture isn’t just academic knowledge — it helps you make better configuration decisions, understand why Nginx behaves a certain way under load, and diagnose performance problems when they appear. This article dissects how Nginx works from the inside, in depth.
The Problem Being Solved: Blocking I/O #
To understand why the event-driven model matters, you first need to understand the root of the problem.
Imagine a program that has to read a file from disk. The simplest way:
// Pseudocode: Blocking I/O (the simple way)
function handleRequest(connection) {
data = readFromDisk("file.html") // ← Program STOPS here
// waiting for the disk to finish reading
// Could take 1-100ms
sendResponse(connection, data)
}
While the program waits for the disk to read the file, nothing happens. The program “blocks” — it can’t do anything except wait. This is blocking I/O.
In the traditional web server model, the solution is to create a new process or thread for each connection:
flowchart TD
subgraph BlockingModel["Blocking Model: One Process Per Connection"]
direction TB
ConnA["Connection A Arrives"] --> ForkA["Fork Process A"] --> WaitA["Process A Waiting for Disk (Blocking)"]
ConnB["Connection B Arrives"] --> ForkB["Fork Process B"] --> WaitB["Process B Waiting for Disk (Blocking)"]
ConnC["Connection C Arrives"] --> ForkC["Fork Process C"] --> WaitC["Process C Waiting for Disk (Blocking)"]
end
style BlockingModel stroke:#d32f2f,stroke-width:2pxThe operating system has to manage $N$ processes at once, each consuming a significant amount of RAM. On top of that, the CPU must constantly switch context (context switching) between those processes — a very expensive low-level operation that creates a heavy overhead.
The problem: a web server spends most of its time waiting — waiting for disk, waiting for backend responses, waiting for client data. That’s a terribly inefficient use of processes/threads.
Non-Blocking I/O: The Foundation of Everything #
Nginx’s solution starts with non-blocking I/O. Instead of waiting for an I/O operation to finish, Nginx asks the OS to notify it when the operation completes:
// Pseudocode: Non-Blocking I/O
function handleRequest(connection) {
// Ask to read the file, but DON'T wait
requestReadFromDisk("file.html", callback: onFileReady)
// Return immediately — the process is free to handle other things
return
}
function onFileReady(data) {
// The OS calls this function when the file has been read
sendResponse(connection, data)
}
With non-blocking I/O, a single process can start many I/O operations and handle their results when ready, without ever “blocking” and waiting.
But this raises a question: how does one process know when each I/O operation completes? That’s the role of the event loop.
The Event Loop: The Heart of Nginx’s Architecture #
The event loop is an infinite loop that keeps running, checking whether there are events to handle:
Event Loop (conceptual):
while (true) {
events = checkForReadyEvents()
// "events" could be:
// - A new incoming connection
// - A file finished reading from disk
// - The backend sent a response
// - A client connection is ready to receive data
// - A timer expired (for timeouts)
for each event in events {
handleEvent(event)
}
// If there are no events, wait a bit
// (the OS will wake us up when a new event arrives)
}
This sounds simple, but its implications are huge: one process can manage thousands of connections at once because it never truly blocks. It always moves on to the next event.
flowchart TD
subgraph EventLoopTimeline["Non-Blocking Event Loop Timeline"]
direction TB
T0["t=0: Accept Connection A"] --> T1["t=1: Start reading file A <br> (Register 'file A ready' event)"]
T1 --> T2["t=2: Accept Connection B <br> (A keeps running asynchronously)"]
T2 --> T3["t=3: Start reading file B <br> (Register 'file B ready' event)"]
T3 --> T4["t=4: Accept Connection C"]
T4 --> T5["t=5: 'file B ready' event fires <br> -> Send response to B"]
T5 --> T6["t=6: 'file A ready' event fires <br> -> Send response to A"]
T6 --> T7["t=7: Connection C ready to send <br> -> Send response to C"]
T7 --> T8["t=8: All done, wait for the next event"]
end
style EventLoopTimeline stroke:#388e3c,stroke-width:2pxNo waiting cycle goes to waste. A single worker process can keep getting real work done, continuously.
Nobody waits idly. One process keeps completing real work, non-stop.
OS Mechanisms: epoll, kqueue, and IOCP #
The event loop needs an efficient way to ask the OS: “which events are ready?” The naive way is to check every connection one by one — but that’s not efficient for thousands of connections.
Modern OSes provide more efficient mechanisms:
epoll (Linux) #
// epoll: Linux mechanism for efficiently monitoring many file descriptors
// Create an epoll instance
int epfd = epoll_create1(0);
// Register the connection you want to monitor
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // Monitor for incoming data, edge-triggered
ev.data.fd = connection_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connection_fd, &ev);
// Wait for events (non-blocking, 1000ms timeout)
int n = epoll_wait(epfd, events, MAX_EVENTS, 1000);
// The OS returns ONLY the file descriptors that are actually ready
// No need to loop and check one by one!
for (int i = 0; i < n; i++) {
handleEvent(events[i].data.fd);
}
Advantages of the epoll mechanism over the older mechanisms (select/poll):
| Characteristic | select / poll (Old Model) | epoll / kqueue (Modern Model) |
|---|---|---|
| Descriptor Delivery | Must send the entire list of file descriptors to the OS kernel on every call | Registers descriptors once; the OS kernel keeps track of the list |
| Checking Method | Kernel must loop through every descriptor one by one, even inactive ones | Kernel directly returns only the active descriptors |
| Algorithm Complexity | $\mathcal{O}(n)$, slows down as total connections grow | $\mathcal{O}(1)$, constant performance regardless of total connections |
| Maximum Connection Limit | Limited (e.g., max 1024 FDs with select) | No hard system limit (depends on RAM & OS file descriptor limits) |
kqueue (BSD/macOS) #
kqueue is the equivalent of epoll on BSD-derived operating systems (including FreeBSD and macOS). Nginx automatically uses kqueue when running on macOS or BSD environments. Its core functionality is the same: efficiently monitoring many connection events asynchronously.
Nginx’s Automatic Detection #
Nginx automatically picks the best event handling mechanism available on the operating system:
# /etc/nginx/nginx.conf
events {
# Nginx usually detects this automatically.
# You can set it manually if needed:
use epoll; # Linux (default on Linux)
# use kqueue; # BSD/macOS
# use select; # Universal fallback (rarely needed)
worker_connections 1024;
}
The Master-Worker Process Model #
Nginx uses a multi-process architecture with two main process types:
flowchart TD
subgraph NginxProcessModel["Nginx Master-Worker Process Model"]
direction TB
M["Master Process (1 Process - User: root) <br> - Reads & validates configuration <br> - Binds to privileged ports (80, 443) <br> - Manages signals (reload, stop, upgrade) <br> - Spawns worker processes"]
M -->|"spawn"| W1["Worker Process #1 (User: nginx/www) <br> - Event Loop & Non-blocking I/O <br> - Handles client HTTP requests"]
M -->|"spawn"| W2["Worker Process #2 (User: nginx/www) <br> - Event Loop & Non-blocking I/O <br> - Handles client HTTP requests"]
M -->|"spawn"| W3["Worker Process #3 (User: nginx/www) <br> - Event Loop & Non-blocking I/O <br> - Handles client HTTP requests"]
end
style M stroke:#0288d1,stroke-width:2px
style W1 stroke:#388e3c,stroke-width:1.5px
style W2 stroke:#388e3c,stroke-width:1.5px
style W3 stroke:#388e3c,stroke-width:1.5pxMaster Process: Duties and Responsibilities #
The master process is the “manager” that never handles HTTP connections directly. Its jobs:
Reads configuration. When Nginx first starts, the master process reads /etc/nginx/nginx.conf, validates it, and stores the parsed configuration.
Binds to privileged ports. Ports below 1024 (like 80 and 443) require root access to bind. The master process does this at startup, then drops privileges to a normal user.
Spawns worker processes. The master process creates worker processes according to the configuration.
Hot reload without downtime. When you run nginx -s reload, here’s the flow:
flowchart TD
subgraph HotReloadProcess["Nginx Hot Reload Workflow (HUP Signal)"]
direction TB
Start["1. Master Process receives HUP Signal"] --> Read["2. Master Process reads new configuration"]
Read --> Check{"Is the Configuration Valid?"}
Check -->|"Yes"| Spawn["3a. Master spawns NEW Worker with new config"]
Spawn --> NewAccept["3b. NEW Worker starts accepting incoming connections"]
NewAccept --> SignalOld["3c. Master sends QUIT signal to OLD Workers"]
SignalOld --> Graceful["3d. OLD Workers finish in-flight requests"]
Graceful --> StopOld["3e. OLD Workers exit once all connections complete"]
StopOld --> ZeroDowntime["4. Result: Zero Downtime Reload!"]
Check -->|"No"| Reject["Reject: Master refuses the new configuration"]
Reject --> KeepOld["OLD Workers keep running with the old configuration"]
KeepOld --> NoDowntime["Result: No service interruption"]
end
style Check stroke:#f57c00,stroke-width:2px
style ZeroDowntime stroke:#388e3c,stroke-width:2px
style NoDowntime stroke:#388e3c,stroke-width:2pxWorker Process: Where the Real Work Happens #
Each worker process runs its own event loop, independently of one another. You’re advised to match the number of worker processes to the number of physical CPU cores on the server:
# Configuration in nginx.conf
worker_processes auto; # 'auto' detects the number of CPU cores automatically
Why is the number of workers recommended to equal the number of CPU cores?
- Fewer than cores: Some CPU cores will sit idle and not be used optimally.
- Equal to cores (Recommended): Each worker process is exclusively tied to a full CPU core, minimizing resource contention.
- More than cores: The operating system is forced to do context switching between workers on the same core, causing unnecessary CPU overhead.
As an illustration, if your server has 4 CPU cores, Nginx will spawn 4 worker processes. If each worker is configured to handle up to 1,024 concurrent connections, your server’s total capacity is $4 \times 1,024 = 4,096$ concurrently active connections, handled asynchronously.
Cache Manager and Cache Loader #
Besides master and workers, Nginx also has optional processes when caching is enabled:
flowchart LR
subgraph CacheProcesses["Additional Nginx Caching Processes"]
direction LR
CM["Cache Manager Process <br> - Monitors cache size on disk <br> - Removes expired/stale entries <br> - Enforces the max cache size limit"]
CL["Cache Loader Process <br> - Runs once at startup <br> - Loads cache metadata into Shared Memory <br> - Warms up the cache quickly"]
end
style CM stroke:#0288d1,stroke-width:1.5px
style CL stroke:#0288d1,stroke-width:1.5pxMemory Model: Why Nginx Is RAM-Efficient #
Memory efficiency isn’t an accident — it’s the result of several deliberate design decisions:
1. Few Processes #
Nginx’s remarkable memory efficiency comes from its different connection management compared to Apache:
| Characteristic | Apache (MPM Prefork) | Nginx (Event-Driven) |
|---|---|---|
| Resource Allocation | 1 Process per 1 Client Connection | 1 Worker Process for Thousands of Client Connections |
| Estimated Memory per Connection | 8 – 10 MB per process | A few kilobytes (stored as structs on the heap) |
| Total RAM (1,000 Connections) | ~8,000 – 10,000 MB (8 - 10 GB) | ~12 MB (Total for all worker processes) |
| Overhead Efficiency | Very wasteful due to context switching | Very efficient, almost no extra context switching |
2. Pool Allocator #
Nginx uses a custom pool-based memory allocator to speed up memory allocation and prevent RAM leaks:
flowchart TD
subgraph MemoryPoolModel["Nginx Memory Pool Allocation Model"]
direction TB
ReqStart["Incoming Request"] --> AllocPool["Allocate Request Pool (One Contiguous Memory Block)"]
subgraph PoolData["Memory Usage within the Pool"]
direction TB
H["Header Parsing"]
U["URL Storage"]
B["Response Buffers"]
V["Temporary Variables"]
end
AllocPool --> PoolData
PoolData --> ReqDone["Request Finished Processing"]
ReqDone --> BulkFree["Bulk Deallocation (Entire Pool Freed at Once)"]
end
style AllocPool stroke:#388e3c,stroke-width:1.5px
style BulkFree stroke:#d32f2f,stroke-width:2pxThe benefits of this Memory Pool model:
- Very fast allocation: Just increments a memory pointer without searching for a free memory block.
- Eliminates memory fragmentation: Memory is allocated in one large contiguous block.
- Free from memory leaks: All memory allocated for a request is deallocated at once in bulk once the request completes.
- Efficient deallocation: Mass memory freeing is much faster than calling
free()one by one for every small variable.
3. Copy-on-Write for Worker Processes #
When the master process creates worker processes using the fork() system call, the Linux kernel optimizes memory using the Copy-on-Write (COW) mechanism:
flowchart TD
subgraph COWModel["Nginx Workers Copy-on-Write (COW) Mechanism"]
direction TB
MP["Master Process Memory Page (10 MB RAM)"]
MP -->|"Fork & Share (Read-Only)"| W1["Worker Process 1 (Sharing Pages)"]
MP -->|"Fork & Share (Read-Only)"| W2["Worker Process 2 (Sharing Pages)"]
MP -->|"Fork & Share (Read-Only)"| W3["Worker Process 3 (Sharing Pages)"]
W2 -->|"Write Event (Change Data)"| ModPage["Duplicate Memory Page Only for the Modified Page"]
end
style MP stroke:#0288d1,stroke-width:2px
style ModPage stroke:#f57c00,stroke-width:1.5pxThe practical result: creating multiple worker processes doesn’t multiply RAM usage linearly, because they share the same memory pages from the master process as long as the data doesn’t change. A new memory page is only copied independently when a write operation happens on that page.
Timeouts and Timers: Managing Slow Connections #
Because Nginx manages thousands of connections at once, it must be able to detect and close connections that are idle or too slow. This is done with a timer system integrated into the event loop:
http {
# How long Nginx waits for the next request
# from the same client (keep-alive)
keepalive_timeout 65;
# How long Nginx waits for the client to send request headers
client_header_timeout 12;
# How long Nginx waits for the client to send the request body
client_body_timeout 12;
# How long Nginx waits for the client to receive the response
send_timeout 10;
}
These timers are implemented as events in the event loop — there’s no separate process “watching” timeouts. When a timer expires, the event loop fires a “timeout” event and Nginx closes that connection.
Shared Memory: Coordinating Between Workers #
Worker processes run independently, but there are a few things that need to be shared between workers:
flowchart TD
subgraph SharedMemSpace["Shared Memory Space (mmap)"]
direction LR
RL["Rate Limit Counters <br> (Worker 1: 5, Worker 2: 3...) <br> Active global accumulation"]
CM["Cache Metadata <br> (File path, size, expiry) <br> Shared lookup for hit/miss"]
end
W1["Worker 1"] <--> SharedMemSpace
W2["Worker 2"] <--> SharedMemSpace
W3["Worker 3"] <--> SharedMemSpace
W4["Worker 4"] <--> SharedMemSpace
style SharedMemSpace stroke:#f57c00,stroke-width:2pxShared memory configuration in Nginx:
http {
# Zone for rate limiting — 10 MB shared memory
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
# Zone for connection limiting
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# Cache zone — shared memory for cache metadata
proxy_cache_path /var/cache/nginx levels=1:2
keys_zone=my_cache:10m
max_size=10g;
# ↑
# 10m = 10 MB shared memory for metadata
# Not for the cache files themselves
}
Thread Pools: Handling Blocking Operations #
Although Nginx is designed for non-blocking I/O, a few operations can’t be done non-blocking in all conditions — especially certain disk operations.
Nginx 1.7.11 introduced thread pools as the solution:
# Thread pool configuration
thread_pool default threads=32 max_queue=65536;
http {
server {
location / {
# Use the thread pool to read files from disk
# Useful when files are often not in the OS page cache
aio threads=default;
sendfile on;
}
}
}
flowchart TD
subgraph ThreadPoolComparison["Execution Model: Without vs With Thread Pool"]
direction TB
subgraph NoThreadPool["Without Thread Pool (Blocking)"]
direction TB
W1["Worker Process"] -->|"Disk I/O Operation"| Disk1[("Disk / HDD (Slow)")]
Disk1 -. "Waiting for Data to Finish (Worker BLOCKED)" .-> W1
W1 -. "Other Client Connections Also Stall" .-> Drop1["Latency / Stall"]
end
subgraph WithThreadPool["With Thread Pool (Non-Blocking)"]
direction TB
W2["Worker Process"] -->|"1. Delegate I/O"| TP["Thread Pool (Worker Threads)"]
TP -->|"2. Process Disk I/O"| Disk2[("Disk / HDD (Slow)")]
W2 -->|"3. Return to Event Loop"| Active["4. Handle Other Connections (Zero Stall)"]
Disk2 -->|"5. File Read Complete"| TP
TP -->|"6. Notify Worker"| W2
W2 -->|"7. Send Client Response"| Done["Successful Response"]
end
end
style NoThreadPool stroke:#d32f2f,stroke-width:1.5px
style WithThreadPool stroke:#388e3c,stroke-width:1.5pxThread pools are very useful for servers with HDDs (not SSDs) that often experience I/O wait, or for cases where large files are frequently accessed.
HTTP/2 and HTTP/3: Modern Nginx Architecture #
Nginx supports HTTP/2 (since 1.9.5) and HTTP/3/QUIC (experimental in mainline). This support changes how Nginx manages connections:
HTTP/2 Multiplexing #
flowchart TD
subgraph ProtocolComparison["HTTP/1.1 vs HTTP/2 Multiplexing Comparison"]
direction TB
subgraph HTTP1["HTTP/1.1 (One Request per TCP Connection)"]
direction TB
C1["Client"] -->|"TCP Connection 1 (GET /page)"| S1["Nginx"]
C1 -->|"TCP Connection 2 (GET /style.css)"| S1
C1 -->|"TCP Connection 3 (GET /app.js)"| S1
style HTTP1 stroke:#7f8c8d,stroke-width:1.5px
end
subgraph HTTP2["HTTP/2 (Multiplexing in One TCP Connection)"]
direction TB
C2["Client"] -->|"Single TCP Connection"| Pipe["One Connection Pipe"]
subgraph Streams["Simultaneous Streams"]
direction LR
st1["Stream 1: GET /page"]
st2["Stream 3: GET /style.css"]
st3["Stream 5: GET /app.js"]
end
Pipe --> Streams --> S2["Nginx"]
style HTTP2 stroke:#388e3c,stroke-width:1.5px
end
endserver {
listen 443 ssl;
http2 on; # Enable HTTP/2 (Nginx 1.25.1+)
# Or: listen 443 ssl http2; (old way)
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# HTTP/2 Server Push (optional)
location / {
http2_push /style.css;
http2_push /app.js;
# Nginx sends the CSS and JS files together with the HTML
# without the browser needing separate requests
}
}
HTTP/3 / QUIC #
# HTTP/3 (QUIC) — requires compilation with quic support
server {
listen 443 quic reuseport; # UDP for QUIC
listen 443 ssl; # TCP fallback for HTTP/2
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
# Inform the browser that QUIC is available
add_header Alt-Svc 'h3=":443"; ma=86400';
}
HTTP/3 uses UDP via the QUIC protocol instead of TCP, eliminating the head-of-line blocking problem at the transport level that still exists in HTTP/2.
Nginx in Containers: Docker and Kubernetes #
Understanding Nginx’s architecture really helps when running it in containers:
Docker #
# Official Nginx Docker image — very small image (~22 MB)
FROM nginx:1.25-alpine
# Copy custom configuration
COPY nginx.conf /etc/nginx/nginx.conf
COPY default.conf /etc/nginx/conf.d/default.conf
# Copy static files
COPY dist/ /usr/share/nginx/html/
EXPOSE 80
# docker-compose.yml
version: '3.8'
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/ssl/nginx:ro
- nginx_cache:/var/cache/nginx
depends_on:
- backend
backend:
image: myapp:latest
expose:
- "3000"
volumes:
nginx_cache:
In containers, Nginx usually runs with a single worker process (because a container usually gets 1 CPU):
# nginx.conf in a container
worker_processes 1; # Or 'auto' — auto-detect CPU count
events {
worker_connections 1024;
}
Kubernetes Nginx Ingress #
# Kubernetes Ingress using the Nginx Controller
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
ingressClassName: nginx
tls:
- hosts:
- example.com
secretName: example-tls
rules:
- host: example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80
The Nginx Ingress Controller in Kubernetes automatically translates this Ingress YAML configuration into the appropriate Nginx configuration and reloads Nginx whenever there’s a change — without downtime.
Debugging Performance: Profiling Nginx #
When facing performance problems in production, several debugging techniques come in handy:
Access Log Analysis #
# Find the 10 slowest URLs
awk '{print $NF, $7}' /var/log/nginx/access.log | \
sort -rn | head -10
# Count requests per second from the log
awk '{print $4}' /var/log/nginx/access.log | \
cut -d: -f1-3 | sort | uniq -c
# Error rate per hour
grep ' 5[0-9][0-9] ' /var/log/nginx/access.log | \
awk '{print $4}' | cut -d: -f1-2 | sort | uniq -c
Nginx Variables for Debugging #
# Add debug headers for proxy troubleshooting
location / {
proxy_pass http://backend;
# Add debug headers (REMOVE in production!)
add_header X-Upstream-Addr $upstream_addr;
add_header X-Upstream-Status $upstream_status;
add_header X-Upstream-Response-Time $upstream_response_time;
add_header X-Request-ID $request_id;
}
strace for Worker Processes #
In extreme situations where you need to detect I/O bottlenecks, you can use the strace utility to see the system calls a worker process is making in real time:
# Find the Nginx worker PID
ps aux | grep "nginx: worker"
# Trace system calls (Warning: causes high overhead, use only for short debugging!)
strace -p <PID> -e trace=network,file 2>&1 | head -100
The non-blocking event-driven model suits a web server because of the workload characteristics of web servers:
| Request Stage | Estimated Duration | Operation Type | Description |
|---|---|---|---|
| Receiving Request Headers | ~0.1 ms | Network I/O | Waiting for data sent from the client over the socket |
| Parsing HTTP Headers | ~0.01 ms | CPU Compute | Reading header data structures in memory |
| Reading File from Disk | ~1 – 10 ms | Disk I/O | Waiting for magnetic platter / SSD reads |
| Proxying to Backend | ~5 – 50 ms | Network I/O + Backend | Waiting for the backend server’s compute process (e.g., database) |
| Sending Response to Client | ~0.5 – 5 ms | Network I/O | Sending data back over the network to the client |
Overall Time Allocation Percentages:
- Total CPU Time (Active): ~1% – 5%
- Total I/O Wait Time (Waiting): ~95% – 99%
Conclusion: A web server spends almost its entire life just waiting for I/O. Nginx’s asynchronous event-driven model ensures that during that waiting period, the worker process isn’t idle — it’s immediately switched to handling other client requests.
Compared to scenarios where the traditional process-per-request (blocking) model is a better fit:
Scenarios where the Blocking (One Process per Connection) model is more suitable:
- Very CPU-intensive data processing (like video compression/encoding, complex math calculations, or machine learning inference).
- Truly sequential operations that don’t need to handle many connections at once (low concurrency).
- One-off scripts that don’t require high scalability.
Scenarios where the Non-Blocking Event-Driven (Nginx) model shines:
- Typical web servers (lots of file and network I/O, but very little CPU processing per request).
- API Gateways and Reverse Proxies (just forwarding requests to backends).
- Real-time applications that keep connections open for a long time (like WebSockets or HTTP long-polling).
- High-traffic load balancers.
Keep-Alive Connections and Pooling #
One of the key optimizations in Nginx’s event-driven model is handling keep-alive connections — both between clients and Nginx, and between Nginx and backends.
Keep-Alive with Clients #
HTTP/1.1 introduced persistent connections — TCP connections that stay open after one request completes, so subsequent requests can reuse the same connection without a new TCP handshake.
flowchart TD
subgraph KeepAliveComparison["Keep-Alive Connection Handling"]
direction TB
subgraph NoKeepAlive["Without Keep-Alive (HTTP/1.0)"]
direction TB
H1["Handshake 1"] --> R1["GET /page"] --> F1["Close Connection (FIN)"]
H2["Handshake 2"] --> R2["GET /style.css"] --> F2["Close Connection (FIN)"]
H3["Handshake 3"] --> R3["GET /app.js"] --> F3["Close Connection (FIN)"]
end
subgraph WithKeepAlive["With Keep-Alive (HTTP/1.1)"]
direction TB
H4["Single TCP Handshake"] --> R4["GET /page"]
R4 --> R5["GET /style.css"]
R5 --> R6["GET /app.js"]
R6 --> Timeout{"keepalive_timeout expired?"}
Timeout -->|"Yes"| F4["Close Connection (FIN)"]
end
endIn Nginx’s event-driven model, keep-alive connections waiting for the next request don’t “consume” resources like they do in Apache. These connections are just registered as waiting events, using very little memory.
http {
# How long keep-alive connections are maintained
keepalive_timeout 65;
# Maximum requests per keep-alive connection
keepalive_requests 1000;
}
Keep-Alive with Backends (Upstream) #
This is often overlooked but very important for proxy performance:
upstream backend {
server 127.0.0.1:3000;
# Keep N connections to the backend so you don't
# need to open a new connection for every request
keepalive 32;
}
server {
location / {
proxy_pass http://backend;
# Required for upstream keepalive to work
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
Without backend keepalive, every request from Nginx to the backend requires a new TCP handshake — adding 1-10ms of latency per request. With keepalive, existing connections are reused.
Buffering and Pipelining #
Nginx uses buffering to optimize data transfer between various components:
flowchart LR
subgraph BufferingMechanism["Nginx Proxy Buffering Mechanism"]
direction LR
Client["Client <br> (Slow/Mobile Connection)"] <-->|"1. Send slowly / <br> 6. Receive slowly"| Buffer["Nginx Buffer <br> (RAM / Temp Disk)"]
Buffer <-->|"2. Send instantly / <br> 3. Receive instantly"| Backend["Backend Server <br> (Fast Loopback/LAN)"]
end
style Buffer stroke:#0288d1,stroke-width:2pxBuffering data flow scenario:
- Nginx receives the request from the client (which can be very slow due to the client’s bandwidth limits).
- Nginx stores the full request body in its buffer first.
- Nginx sends the complete request to the backend all at once (very fast via inter-process communication or LAN).
- The backend processes it immediately and returns a response instantly.
- Nginx stores the full backend response in its buffer.
- Nginx sends the response from the buffer to the client gradually (even if the client’s connection is very slow).
- Without buffering: The backend is forced to wait for Nginx to finish sending data byte-by-byte to the slow client. As a result, the backend process stays “busy” and can’t serve other new requests.
- With buffering: The backend process finishes in milliseconds and is immediately free to handle the next request queue, while Nginx handles the slow transmission to the client on its own at the event loop level.
location / {
proxy_pass http://backend;
# Buffer responses from the backend
proxy_buffering on;
proxy_buffer_size 4k; # Buffer for headers
proxy_buffers 8 4k; # 8 buffers × 4k = 32k for the body
proxy_busy_buffers_size 8k; # Max buffer currently being sent
# If the response is larger than the buffer, write to disk
proxy_temp_path /var/cache/nginx/temp;
proxy_max_temp_file_size 1024m;
}
Nginx Workers: Isolation and Fault Tolerance #
One rarely-discussed benefit of the multi-worker model is fault isolation:
flowchart TD
subgraph FaultTolerance["Process Isolation & Self-Healing (Fault Tolerance)"]
direction TB
Master["Master Process"] -->|"Detects crash via SIGCHLD"| CrashEvent{"Worker 2 Crash!"}
CrashEvent -->|"Yes"| SpawnNew["Master spawns NEW Worker 2 (< 1 second)"]
subgraph ActiveWorkers["Other Workers Keep Serving Traffic"]
direction LR
W1["Worker 1"]
W3["Worker 3"]
W4["Worker 4"]
end
CrashEvent --> ActiveWorkers
SpawnNew -->|"Back to serving traffic"| W2New["New Worker 2"]
end
style Master stroke:#0288d1,stroke-width:2px
style CrashEvent stroke:#d32f2f,stroke-width:2px
style ActiveWorkers stroke:#388e3c,stroke-width:1.5pxFault handling scenario (fault isolation):
- Without the multi-worker model: If the single web server process crashes, the entire web service goes down immediately (downtime).
- With the multi-worker model: If one worker process (say Worker 2) crashes due to an app bug or running out of memory, the master process detects it instantly via the
SIGCHLDsignal. The master immediately spawns a replacement worker in under a second. Meanwhile, the other workers (Workers 1, 3, and 4) keep actively serving incoming requests without any disruption. End users don’t notice any service interruption.
The master process actively monitors worker processes. If a worker dies abnormally, the master immediately spawns a replacement:
# You can see this activity in the Nginx error log when a failure occurs:
# 2026/01/15 10:23:45 [alert] 1234#1234: worker process 5678 exited on signal 11
# 2026/01/15 10:23:45 [notice] 1234#1234: start worker process 5679
OS-Level Optimizations: Nginx and the Kernel #
Nginx works closely with Linux kernel features for maximum performance:
sendfile() — Zero Copy #
flowchart TD
subgraph ZeroCopyComparison["I/O Flow Comparison: Traditional vs Zero-Copy (sendfile)"]
direction TB
subgraph TraditionalIO["Traditional Method (Without sendfile)"]
direction TB
D1[("File on Disk")] -->|"1. read() syscall"| KC1["Kernel Page Cache"]
KC1 -->|"2. Copy data to User Space"| NP["Nginx Process Memory"]
NP -->|"3. write() syscall"| SB1["Kernel Socket Buffer"]
SB1 -->|"4. Send data"| Net1["Network Card"]
style TraditionalIO stroke:#7f8c8d,stroke-width:1.5px
end
subgraph ZeroCopyIO["Zero-Copy (With sendfile)"]
direction TB
D2[("File on Disk")] -->|"1. sendfile() syscall"| KC2["Kernel Page Cache"]
KC2 -->|"2. Direct transfer via DMA"| SB2["Kernel Socket Buffer"]
SB2 -->|"3. Send data"| Net2["Network Card"]
style ZeroCopyIO stroke:#388e3c,stroke-width:1.5px
end
endhttp {
sendfile on; # Enable zero-copy
tcp_nopush on; # Buffer TCP packets until full before sending
# (pairs with sendfile)
tcp_nodelay on; # Send immediately for keep-alive connections
# (after sendfile/tcp_nopush finishes)
}
mmap() for Large File Serving #
For very large files, Nginx uses mmap() — memory-mapped files — which lets the OS manage the file cache in the kernel efficiently:
flowchart TD
subgraph MmapServing["Large File Serving via mmap()"]
direction TB
LargeFile[("Large File (Video / ISO)")] -->|"mmap() syscall"| MapSpace["Nginx maps the file into Virtual Address Space"]
MapSpace --> LazyLoad["Kernel manages page loading (Lazy Loading)"]
LazyLoad --> ReadReq["Client requests reading specific bytes"]
ReadReq --> PageFault{"Is the page in RAM?"}
PageFault -->|"No (Page Fault)"| LoadDisk["Kernel reads the page from disk into the RAM Page Cache"]
PageFault -->|"Yes (Cache Hit)"| DirectServe["Kernel sends the data straight from RAM (Zero-copy)"]
LoadDisk --> DirectServe
end
style PageFault stroke:#f57c00,stroke-width:1.5px
style DirectServe stroke:#388e3c,stroke-width:2pxDiagnosing Performance with the Nginx Status Module #
Nginx provides a built-in module for viewing real-time statistics:
server {
listen 8080;
server_name localhost;
location /nginx_status {
stub_status on;
allow 127.0.0.1; # Only allow localhost
deny all;
}
}
# Example output from /nginx_status:
curl http://localhost:8080/nginx_status
Active connections: 291
server accepts handled requests
16630948 16630948 31070465
Reading: 6 Writing: 179 Waiting: 106
Here's how to interpret the statistics above:
* **Active connections**: `291` means there are 291 client connections currently active on the server.
* **accepts**: `16630948` is the total number of connections Nginx has accepted since the server started.
* **handled**: `16630948` is the number of successfully handled connections. A value equal to `accepts` means no connections were dropped.
* **requests**: `31070465` is the total number of processed requests. This number is higher than the connection count because of *Keep-Alive*, which serves multiple requests over one TCP connection.
* **Reading**: `6` is the number of connections where a worker process is currently reading request headers from clients.
* **Writing**: `179` is the number of connections where a worker is writing/sending response data back to clients.
* **Waiting**: `106` is the number of idle keep-alive connections waiting for the next request. In Nginx, these waiting connections are extremely power-efficient and don't burden memory.
Practical Implications for Configuration #
Understanding this event-driven architecture helps you configure Nginx properly for production performance:
# nginx.conf
# Number of workers = number of CPU cores
worker_processes auto;
# CPU affinity: bind workers to specific cores
# to avoid cache thrashing
worker_cpu_affinity auto;
events {
# Connections per worker
# Total connections = worker_processes × worker_connections
worker_connections 1024;
# Allow workers to accept multiple connections at once
# (not one by one)
multi_accept on;
# Event mechanism (auto-detected, but can be overridden)
use epoll;
}
http {
# Enable sendfile for zero-copy file serving
sendfile on;
# tcp_nopush: buffer data before sending
# (optimal with sendfile)
tcp_nopush on;
# tcp_nodelay: send immediately without buffering
# (for keep-alive connections, after tcp_nopush)
tcp_nodelay on;
}
Don’t set
worker_connectionstoo high without considering operating system limits. Each active connection uses one file descriptor. Make sure theulimit -nlimit (maximum open files) on the OS is set high enough:# Check the current limit ulimit -n # Nginx sets this automatically, but you can set it manually in limits.conf: # /etc/security/limits.conf nginx soft nofile 65536 nginx hard nofile 65536
Summary #
- Event-driven non-blocking I/O is the core of Nginx’s architecture — one worker process manages thousands of connections without blocking.
- The Master-Worker model: the master process manages configuration and worker processes; worker processes handle actual requests.
- epoll (Linux) / kqueue (BSD) are the OS mechanisms that let Nginx efficiently monitor thousands of file descriptors at once.
- Number of workers = number of CPU cores is the general guideline — more isn’t always better because of context switching.
- Memory efficiency comes from having only a few worker processes (not one per connection) plus an efficient custom pool allocator.
- Hot reload without downtime: the master spawns new workers with the new config, old workers finish existing connections then exit.
- Shared memory is used for worker coordination: rate limiting counters, cache metadata, and statistics.
- This model suits web servers because web servers spend 95%+ of their time waiting for I/O, not CPU compute.