← Back to index

NGINX — Internal Architecture

A developer's guide to how NGINX actually works under the hood: the process model, the event-driven core, how a request flows through the processing phases, and how it proxies, load-balances, caches, and reloads without dropping a single connection.

Student edition · understand, apply, verify

Understand event-driven proxying and reloads without assuming idle connections are free or every reload is lossless.

What you should be able to do: Configure proxy state, buffering, resource limits, and graceful drain behavior with explicit trust/cache policies.

Architecture tutorial for a student with basic programming knowledge. Defaults, guarantees, file layouts, and APIs are release/configuration-sensitive. Toy models and pseudocode do not implement the named product.

NGINX is a high-performance web server, reverse proxy, load balancer, and HTTP cache. It was written to solve one specific problem: serving tens of thousands of simultaneous connections on a single machine without the memory and context-switch overhead of the thread-per-connection model that dominated when it was created. Almost every design decision flows from that goal. Instead of one operating-system thread per client, NGINX uses a small fixed number of worker processes, each running a single-threaded event loop that multiplexes thousands of non-blocking connections. Understanding NGINX means understanding that event-driven core and the way work is organized around it.

Contents

  1. Design Goals & the C10k Problem
  2. Process Architecture
  3. Event-Driven Model
  4. Request Lifecycle
  5. Reverse Proxy & Upstreams
  6. Load Balancing
  7. Caching & Shared Memory
  8. Graceful Reload & Binary Upgrade
  9. Summary
  10. Concepts explained
  11. Worked example
  12. Practice and verification

Concepts explained in this guide

Read these definitions first, or use the linked terms as you go. The examples and limitations are local to this guide; companion pages are optional.

Event loops and nonblocking I/O

One loop handles ready sockets/callbacks instead of one waiting thread per connection. Blocking or long CPU work delays other clients.

Example. Ten thousand sockets share a loop, but their file descriptors, state, and buffers still consume memory.

Boundary. Readiness is not a whole request. Preserve partial read/write parser state; background threads and defaults depend on release/configuration.

Reverse proxy, buffering, and graceful reload

A proxy relays to upstreams. Buffering can let backends finish ahead of slow clients; graceful reload sends new work to new workers while old work drains.

Example. Listening sockets remain bound, but WebSockets, shutdown deadlines, failed startups, and resource limits need policy.

Boundary. Buffering streams as data arrives, not always whole-response-before-send. Shared upstream state and I/O thread pools require configuration.

Cache-aside and invalidation

A read checks a cache, reads the authoritative store on a miss, then fills the cache. A write commits to the store and invalidates or refreshes the cached value. TTL means time to live, not a freshness proof.

Example. A reader fetches version 1, a writer commits version 2 and invalidates, then the delayed reader caches version 1. DB-first invalidation alone does not prevent this refill race.

Boundary. Use invalidation-aware fill leases or version fences for stronger behavior; a TTL only bounds one entry's residence under additional assumptions. Authorization and revocation need an explicit stale-data policy.

Backpressure and load shedding

Backpressure tells an upstream producer that the downstream stage cannot accept more work. Buffering delays that signal; shedding deliberately rejects or drops lower-priority work when a bound is reached.

Example. A sink accepts 100 events/s while input is 150/s. A 1000-event queue fills in 20 seconds. The system must then block, reject, or apply its documented drop policy.

Boundary. A finite queue cannot absorb an infinite outage. Prioritized messages still need hard quotas, deadlines, and bounded client queues; otherwise an exemption can exhaust the system.

Wall and monotonic clocks

A wall clock maps to civil/epoch time and can jump because of correction. A monotonic clock measures elapsed time locally without wall-clock jumps, but its origin is not a shared global epoch.

Example. Use an epoch timestamp to label an event and a monotonic deadline to bound how long a process waits for the next usable timestamp.

Boundary. Neither establishes causal order across machines. Monotonic readings cannot be compared between hosts or assumed to survive reboot with the same origin.

1. Design Goals and the C10k Problem

NGINX was created to answer the C10k problem: how does a single server handle ten thousand concurrent connections? The traditional answer — one thread (or process) per connection — does not scale. Each thread carries a stack of one to several megabytes, so ten thousand threads burn gigabytes of memory before doing any work. Worse, the kernel must constantly context-switch between them, and most of those threads are idle, blocked waiting on a slow client or a slow network. The cost grows with the number of connections, not the amount of useful work.

Threads vs event loop
Thread-per-connection memory grows with the number of connections; NGINX's event loop holds a few KB of state per connection, so memory stays low and predictable.

NGINX inverts this. A single worker thread asks the kernel "which of my thousands of sockets is ready to read or write?" and then services exactly those, never blocking on the rest. The result is the property the whole project is built around: throughput scales with hardware while memory use stays flat and predictable as connections climb.

GoalHow NGINX achieves it
High concurrencyOne worker multiplexes thousands of connections via a non-blocking event loop, not one thread per connection.
Low, predictable memoryPer-connection state is a small struct (a few KB), not a thread stack. Memory tracks active work, not connection count.
Full use of multiple coresA handful of worker processes — typically one per CPU core — each run their own event loop in parallel.
Operational stabilityConfig reloads and even binary upgrades happen with zero dropped connections.
Efficient as a proxyBuffering, upstream keepalive, and on-disk caching shield slow clients from fast backends and vice versa.

2. Process Architecture

The master manages configuration, workers, and listening sockets; workers serve requests through event loops. Running the master as root is not universal: unprivileged ports/capabilities/container configuration can permit other setups. Worker count is a tuning choice, not a theorem that one per core always maximizes throughput.

Idle connections retain descriptors, state, timers, TLS and buffers. Proxying can require upstream sockets too; budget worker_connections, file limits, memory, CPU/TLS, and active work. I/O pools/AIO and module behavior are configured/version-specific—blocking disk work is not always automatically offloaded.

3. The Event-Driven Model

Inside each worker is the heart of NGINX: a single-threaded event loop built on the operating system's scalable I/O readiness API — epoll on Linux, kqueue on BSD/macOS, and others elsewhere. Every socket is set to non-blocking mode. Rather than calling read() and waiting, the worker registers all its sockets with epoll and makes a single call — epoll_wait() — that blocks until any of those thousands of file descriptors is ready. The kernel returns just the ready ones, and the worker handles each, then loops.

The event loop
One worker thread monitors thousands of sockets; epoll returns only the ready descriptors, and handlers must never block — a single blocking call would freeze every connection on that worker.

This is the opposite of the thread-per-connection model. There, each connection has a dedicated thread that calls a blocking read(); the kernel parks that thread until data arrives. With ten thousand connections you need ten thousand parked threads. With the event loop, one thread asks "who is ready?" and services only those — so the number of threads is tied to the number of cores, not the number of connections.

The cost of this model is a strict discipline: nothing in a handler may block. A blocking disk read, a synchronous DNS lookup, or a slow database call would freeze the entire worker and every connection it is serving, not just the one that issued it. NGINX therefore uses non-blocking I/O everywhere it can, and for the unavoidable blocking operations (notably reading large files from disk) it offloads them to a small thread pool so the event loop stays responsive.

run_event_loop(sockets):
  epoll = epoll_create()
  for s in sockets: epoll.add(s, READABLE)
  while True:
    events = epoll.wait(timeout = next_timer())   # blocks once, on all fds
    for ev in events:
      conn = ev.connection
      handler = conn.current_handler              # read / write / proxy ...
      handler(conn)        # MUST be non-blocking; returns quickly
    run_expired_timers()   # keepalive, proxy timeouts, etc.
A connection is just a small state machine plus a buffer. When it has nothing to do (waiting on a slow client or backend) it costs almost nothing — it sits in epoll until the kernel says it is ready again. That is why idle connections are cheap and why memory tracks active work, not connection count.

4. Request Lifecycle

When a connection is accepted, the worker reads and parses the request line and headers, then runs the request through an ordered series of processing phases. NGINX's modular architecture hangs handler modules off these phases; the phase order is what makes configuration directives composable and predictable. A request always flows through the phases in the same sequence.

Request phases
After accept and header parsing, the request passes through ordered phases: server selection, rewrite, access/auth, content generation, and logging.

The phases, in order, do roughly the following:

PhaseWhat happens
Accept & readAccept the connection, read the request line and headers, parse them into a request structure.
Server selectionMatch the Host header against server_name to pick the virtual server (server { } block).
Find locationMatch the request URI against the location blocks to select the configuration that applies.
RewriteApply rewrite / return rules; URIs may be modified and re-matched.
Access / authEnforce allow/deny, authentication (auth_basic, auth_request), and rate limits.
ContentGenerate the response: serve a static file, run a handler, or proxy to an upstream backend.
LogWrite the access log line after the response is sent.

For static content the content phase reads a file from disk (or the page cache); for dynamic content it proxies the request to a backend, which is the subject of the next section. The response then travels back out through any configured output filters (gzip, headers, chunked encoding) before being written to the client.

handle_request(conn):
  req = parse_request_line_and_headers(conn)
  server   = match_server_name(req.host)          # server { }
  location = match_location(server, req.uri)       # location { }
  apply_rewrites(req, location)                    # may re-loop
  if not access_allowed(req, location):
    return respond(req, 403)
  resp = run_content_phase(req, location)          # file or proxy_pass
  resp = run_output_filters(resp)                  # gzip, headers ...
  send(conn, resp)
  write_access_log(req, resp)

5. Reverse Proxy and Upstreams

proxy_pass routes to a configured backend. With buffering enabled, NGINX reads ahead into bounded buffers and possibly configured temporary files while serving the client. It need not read the whole response before sending any bytes, and unbounded/streaming responses or storage limits can still tie up an upstream.

Upstream keepalive controls an idle pool per relevant worker/configuration, not a hard total-active-connection cap. Explicit HTTP version/connection behavior makes examples portable across releases. Configure trusted forwarding headers deliberately; do not trust arbitrary client-supplied identity headers as authenticated identity.

# Educational local HTTP example; adapt TLS, paths, trust, and limits.
upstream app {
    server 127.0.0.1:9000;
    keepalive 16;
}
server {
    listen 8080;
    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_buffering on;
        proxy_connect_timeout 2s;
        proxy_read_timeout 30s;
    }
}

The timeouts are examples of operation/inactivity policies, not a global whole-request deadline or retry-safety guarantee.

6. Load Balancing

When an upstream block lists several servers, NGINX load-balances requests across them. The balancing method is chosen per upstream, and NGINX also tracks backend health so it can route around failures.

Load balancing methods
NGINX distributes requests across healthy backends by the configured method, and a passive health check marks a failing peer down after repeated errors.
MethodHow it picks a backendUse when
Round robin (default)Cycles through servers in order, optionally weighted.Backends are interchangeable and stateless.
least_connSends each request to the server with the fewest active connections.Request durations vary, so even rotation would imbalance load.
ip_hashHashes the client IP to always map a client to the same backend.You need session stickiness without shared session storage.
hash $keyHashes an arbitrary key (URI, header, cookie) to a backend, optionally consistent.Stickiness by something other than IP, e.g. cache affinity by URL.

Passive health checks come built in: with max_fails and fail_timeout, if a backend produces too many failed responses within the window, NGINX marks it down, stops sending it traffic, and retries it only after the timeout. (NGINX Plus adds active health checks that probe a dedicated health endpoint on a schedule rather than waiting for real requests to fail.)

function pick_backend(upstream, req):
  candidates = [s for s in upstream.servers if s.is_up()]
  if upstream.method == "least_conn":
    return min(candidates, key = lambda s: s.active_conns)
  if upstream.method == "ip_hash":
    return candidates[hash(req.client_ip) % len(candidates)]
  return round_robin_next(candidates)        # default, weighted

# passive health: on repeated errors, take the peer out of rotation
on_response_error(server):
  server.fails += 1
  if server.fails >= max_fails:
    server.mark_down(for = fail_timeout)

7. Caching and Shared Memory

Proxy caching uses disk objects and a shared metadata zone. Correct cache keys include every dimension that changes authorized representation—host/route/query and relevant tenant/user/content negotiation policy. Avoid caching private authenticated responses across users unless a reviewed policy explicitly permits it.

Freshness uses supported origin headers and cache directives; TTL is not proof the origin never changed. Keys, bypass/no-cache rules, Vary, Set-Cookie/Authorization behavior, stampede locking, and stale-serve policy matter. Shared upstream health/connection state requires its supported shared-zone configuration; it is not automatically global merely because workers share a cache index.

8. Graceful Reload and Binary Upgrade

Validate the configuration before graceful reload. The master keeps required listening sockets and starts replacement workers, while old workers stop accepting/drain under the supported configuration. Failed starts, resource pressure, long-lived protocols, shutdown deadlines, and client reconnects still need policy.

Binary upgrade procedures coordinate old/new masters and sockets, but health validation, compatible modules/configuration, traffic convergence, and safe rollback are operational prerequisites. TERM is fast termination, not graceful rollback. 'No port-unbound gap' is not the same as a universal zero-failed-request guarantee.

Observe errors, connections, latency, and worker generations before retiring the old fleet. Test streaming/WebSocket behavior separately; document a bounded drain and client recovery strategy.

Worked example: a slow response and a graceful reload

Use this example to check which promise each mechanism actually provides.

a slow response and a graceful reloadProxy an upstream response: NGINX can forward bytes as they arrive while buffering excess; it does not universally wait to store the whole response first.. Bound buffers and connections: Each client/upstream socket consumes descriptors/state/buffers. worker_connections and OS limits count relevant sockets, not just users.. Load a valid new configuration: New workers accept new traffic while old workers drain according to supported behavior.. Handle a long-lived session: WebSockets/streaming, shutdown deadlines, capacity, and failed starts can disrupt traffic. Measure recovery; do not promise zero drops from a signal alone.1Proxy an upstream response2Bound buffers and connections3Load a valid new configuration4Handle a long-lived session
Sequence for this example. The table explains the evidence and decision at each step.
StepEvidence, decision, and expected result
Proxy an upstream responseNGINX can forward bytes as they arrive while buffering excess; it does not universally wait to store the whole response first.
Bound buffers and connectionsEach client/upstream socket consumes descriptors/state/buffers. worker_connections and OS limits count relevant sockets, not just users.
Load a valid new configurationNew workers accept new traffic while old workers drain according to supported behavior.
Handle a long-lived sessionWebSockets/streaming, shutdown deadlines, capacity, and failed starts can disrupt traffic. Measure recovery; do not promise zero drops from a signal alone.
Check your understanding: Does proxy_buffering always release the backend immediately?

No. It can decouple a fast finite response if buffers/storage permit; streaming or resource limits can keep the upstream occupied.

Run and change the example

Download guide_labs.py. Python 3.10+, standard library only. In its directory, run:

python3 guide_labs.py proxy
python3 guide_labs.py test

The model illustrates finite queue growth and a bounded admission policy; the guide also shows a complete local NGINX configuration to adapt.

The program tests its stated finite contracts. It is an educational model, not an implementation of the named production product or a proof of unmodeled distributed faults.

Sources and further verification

9. Summary

NGINX is a small set of ideas applied consistently:

ConcernMechanism
How does it handle so many connections?A non-blocking event loop (epoll/kqueue) multiplexing thousands of sockets per worker.
Why is memory low and predictable?Per-connection state is a small struct, not a thread stack; memory tracks active work.
How does it use all the cores?One master plus N single-threaded workers, typically one per CPU core, sharing the listening sockets.
How is a request processed?Accept and parse, then ordered phases: server selection, rewrite, access/auth, content, log.
How does it proxy efficiently?Response buffering plus upstream keepalive decouple slow clients from fast backends.
How does it balance and stay healthy?Round robin / least_conn / ip_hash / hash, with passive health checks taking bad peers out of rotation.
How does state cross workers?Shared memory zones: cache index, rate limits, upstream health, TLS sessions.
How does it update with no downtime?Signals (HUP, USR2, QUIT) reload config or swap the binary while the master holds the sockets.
The recurring theme: a stable master owns the sockets, single-threaded non-blocking workers do the work, and anything shared lives in explicit shared memory. Nothing blocks, nothing is duplicated needlessly, and nothing has to stop to reconfigure.

⌂