System Design Handbook · llms.txt
This chapter answers six questions:
We'll keep using Crumb, the recipe-sharing site from the Scalability chapter. Crumb has several app servers behind a load balancer, a cache, a database with read replicas, and workers that process photo uploads from a queue. In this chapter, Crumb puts a reverse proxy at its front door, splits its one big application into smaller services, and then has to solve the problems that splitting creates.
On your laptop, a browser talks straight to your application on port 8080. In production you almost never let internet clients do that. If clients reached every server directly, each server would have to handle encryption, block abusive clients and serve files, and clients would need to know the address of every server.
Instead, requests come in through a front door. A proxy is a server that makes requests on someone else's behalf. A reverse proxy is a web server that sits in front of your own servers and makes requests to them on behalf of clients. It brings your internal services together behind one public address, so the outside world sees a single, unified interface.
Here is what happens when a Crumb user opens a recipe:
crumb.example. That name points to the reverse proxy, not to any app server.The browser never learns which server did the work. NGINX describes the same flow: a reverse proxy "accepts a request from a client, forwards it to a server that can fulfill it, and returns the server's response to the client" (NGINX glossary, archived).
The two kinds of proxy differ in whose behalf they act on.
api.example.com and never learn which server answered.The same software often does both jobs. Where it sits, and who configured it, decides which kind it is.
Because every request passes through the reverse proxy, it is a natural place to do work that would otherwise be repeated on every server. The NGINX glossary page linked above lists most of these benefits.
Here is a short NGINX configuration that gives Crumb several of these benefits. It is a sketch: the paths and addresses are made up.
upstream crumb_app {
server 10.0.1.10:8080;
}
server {
listen 443 ssl;
server_name crumb.example;
ssl_certificate /etc/nginx/certs/crumb.pem;
ssl_certificate_key /etc/nginx/certs/crumb.key;
gzip on;
gzip_types text/css application/javascript application/json;
location /static/ {
root /var/www/crumb;
}
location / {
proxy_pass http://crumb_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
listen 443 ssl and the two ssl_certificate lines turn on TLS termination.gzip on compresses the listed types of response./static/ block serves files straight from disk.upstream. The X-Forwarded-For header tells the app the user's real IP address, since the connection now comes from the proxy.A load balancer and a reverse proxy both sit between clients and servers, take requests from the clients and return responses from the servers. So people often confuse them. The difference is in why you deploy each one.
upstream block in the Crumb configuration above turns into load balancing as soon as you list a second server in it.| Load balancer | Reverse proxy | |
|---|---|---|
| Main reason to deploy it | Spread work over several servers and route around failed ones | Give one public face to your servers and take shared work off them |
| Useful with one server? | No | Yes |
| Typical extras | Health checks, sticky sessions | SSL termination, compression, caching, static files |
| Common software | HAProxy, NGINX, Envoy, cloud load balancers | NGINX, HAProxy, Envoy, Apache |
In practice, the two roles blur. Most software load balancers that read HTTP are also reverse proxies, and most reverse proxies can balance load.
A reverse proxy holds a connection open to every client that is talking to the site. On a busy site, that can be tens of thousands of connections at once. How a server copes with that many connections is the reason NGINX, and later Envoy, replaced the classic Apache setup as the usual front door.
In 1999 Dan Kegel wrote an essay on the C10K problem: how to have one server handle 10,000 clients at the same time. Hardware was already fast enough. The bottleneck was the way servers used the operating system.
The traditional model gives each connected client its own operating system thread. Apache's old prefork mode gave each client its own process. The thread reads the request, runs the handler, waits on the database and writes the response. Then it waits for the client's next request on the same keep-alive connection.
Code written this way is easy to follow. But its cost grows with the number of connections, even idle ones:
Event-driven servers turn this around. They run a small, fixed number of workers, usually one per CPU core. NGINX uses worker processes, Envoy uses worker threads, and Node.js uses a single event-loop thread. Each worker handles thousands of connections by never waiting on any one of them. It relies on the kernel's readiness notification APIs: epoll on Linux and kqueue on the BSDs and macOS.
epoll_ctl on Linux). It says which events it cares about, such as "readable" or "writable".epoll_wait, which sleeps until at least one registered socket is ready.An idle connection now costs only some socket state in the kernel and a small record in the proxy. The NGINX home page says "10,000 inactive HTTP keep-alive connections take about 2.5M memory." That figure leaves out the kernel's socket buffers. Still, it's why one modest machine running NGINX or Envoy can hold tens or hundreds of thousands of mostly idle connections.
There is a catch: nothing in a worker may block. A slow disk read or a long computation stalls every connection that worker owns. Proxies suit this model because most of their work is moving bytes from one socket to another. Apache later added its event MPM, which hands idle keep-alive connections to an event loop for the same reason.
An API gateway is a reverse proxy that specializes in APIs. It knows who each caller is and which API they are calling, and it applies rules per caller. Every API gateway is a reverse proxy, but not every reverse proxy is an API gateway.
The line between the two is blurry, and products sit all along it. Envoy is used as a plain proxy, as a sidecar and as a gateway. NGINX gains gateway features through modules. The table shows the typical emphasis of each.
| Capability | Reverse proxy (NGINX, HAProxy) | API gateway (Kong, AWS API Gateway, Envoy-based gateways) |
|---|---|---|
| Main job | Terminate TLS, route and load balance HTTP and TCP, cache, compress. | Manage APIs as a product: who may call what, how often, and in what shape. |
| Configuration | Mostly files, applied with a graceful reload. | Usually changed through an API or control plane, often per route or per caller. |
| Authentication | TLS, basic auth, perhaps a call out to an auth service. | Checks API keys, OAuth 2.0 access tokens and JWTs, and the scopes inside them. |
| Rate limiting | Per client IP or per connection. | Per user, API key or pricing plan, often shared across gateway nodes in a store such as Redis. |
| Transformation | Headers, compression. | Rewrites requests and responses, translates JSON to gRPC, validates requests against a schema. |
| Observability | Access logs and basic metrics. | Per-caller analytics, distributed tracing, usage reports for billing. |
In a system made of many services, the gateway is the single entry point for traffic from outside. A typical request passes through these steps:
Without a gateway, every service has to check tokens itself. Each one parses JWTs, fetches and rotates the identity provider's public keys, and rejects bad requests. When a dozen services in different languages each do this, you get a dozen slightly different bugs.
With a gateway, the work happens once, at the edge:
X-User-Id: 104 header, or forward the verified token itself.Step 3 has a catch. The gateway must remove any X-User-Id header the client sent. Otherwise a caller could claim to be anyone. Services, in turn, must accept that header only from the gateway, which is one reason internal traffic is authenticated with mutual TLS (mTLS). Many teams also let each service check the token again, so that a service exposed by mistake isn't wide open. Security covers tokens and mTLS in detail.
Browsers and outside developers want HTTP with JSON. Many internal services prefer gRPC, which sends Protocol Buffers (a compact binary format) over HTTP/2. Its contracts are typed and generated from a schema. The protocols chapter explains gRPC itself.
A gateway can translate between the two. A client sends GET /v1/users/104. The gateway turns it into a GetUser gRPC call with user_id = 104, then converts the binary reply back into JSON. This is called gRPC-JSON transcoding. The mapping is declared in the service's .proto file with HTTP annotations. Envoy, Google Cloud's API gateways and others implement it. Outside clients get a normal REST API, internal services stay on gRPC, and either side can change its internals without the other noticing.
A related option, gRPC-Web, lets code in the browser call gRPC services. A proxy in the middle translates between what browsers can send and full gRPC.
One general-purpose API rarely suits every client:
The Backend-for-Frontend pattern, named by Sam Newman, gives each kind of client its own thin backend. The team that builds the client also owns its backend.
Each BFF changes at the pace of its own frontend. The core services don't fill up with requests like "add this field for the iOS app." The costs are more services to run, and logic that can get copied between BFFs if you aren't careful. Keep business rules in the core services, and put only combining and reshaping in the BFF.
A public API for outside developers is usually a separate product, with its own versions, API keys and quotas. The API gateway often serves it directly, without a BFF.
At first, each Crumb app server does everything. It renders HTML pages, runs the business logic (search, saving recipes, comments) and talks to the database. That's fine until Crumb launches a mobile app.
The mobile app needs Crumb's data as JSON and never asks for HTML pages. If the code that renders pages and the code that runs business logic live in the same servers, Crumb can only scale them together. So Crumb splits its servers into two layers:
The split gives Crumb four things:
The same idea can go further. The single responsibility principle says each part of a system should do one job well. Applied to services, it argues for small, autonomous services that each own one job and work together. A small team that owns a small service understands all of it, can change it without asking other teams, and can plan aggressively for rapid growth. Taken to its end, this idea is called microservices.
Microservices are a way of building one application as a suite of small, modular services that can each be deployed on their own. Each service runs as its own process. Services talk to each other through a well-defined, lightweight mechanism, usually HTTP with JSON, gRPC, or messages on a queue. Each one serves a business goal, such as "search recipes" or "store photos."
James Lewis and Martin Fowler's 2014 article describes microservices as "suites of independently deployable services," usually organized "around business capability." The opposite is a monolith: one application that is built and deployed as a single unit.
A photo-sharing site like Pinterest could have these microservices: user profile, follower, feed, search and photo upload. Crumb's split looks similar:
| Crumb service | Its job | Owns this data |
|---|---|---|
| Users | Accounts, profiles, login | User records |
| Recipes | Create, edit and read recipes | Recipe records |
| Search | Answer recipe searches | A search index |
| Comments | Comments and ratings | Comment records |
| Photos | Accept uploads, store and resize photos | Photo files and metadata |
Each service keeps its own data. Other services ask it through its API, and never read its database directly. That rule is what lets a team change its service's internals without breaking anyone else.
Uber's 2020 post, Introducing Domain-Oriented Microservice Architecture, shows these costs at scale. By then Uber had "around 2,200 critical microservices." The post describes an outage investigation in which "engineers had to work through around 50 services across 12 different teams" to find the root cause. It also says that "in order to build a simple feature an engineer often has to work across multiple services," each owned by different people. Uber's response was to group related services into domains, each "a collection of one or more microservices tied to a logical grouping of functionality," with a gateway as the single entry point into each domain.
For this reason, many teams start with a single, well-organized application and split off services only when a part of it clearly needs its own team, deploys or scaling. Martin Fowler's Monolith First (2015) reports that "almost all the successful microservice stories have started with a monolith that got too big and was broken up."
On a fixed set of servers, a service can reach its database at a known hostname such as db.internal:5432. In a cloud or Kubernetes setup, instances are short-lived. Autoscalers add and remove them, and the scheduler moves containers between machines, so IP addresses change all the time.
So how does Crumb's recipe service find a healthy instance of the search service right now? Every approach uses a service registry: a list of the live instances of each service, with their names, addresses and ports. Instances add themselves when they start, and health checks remove the ones that stop working.
Three tools are commonly used to keep this list.
etcd and ZooKeeper both keep their data consistent across several machines using consensus. The consensus chapter explains how.
Health checks confirm that an instance is really working. They are often done over HTTP. Each instance of Crumb's search service exposes an endpoint such as GET /health, and the registry calls it at a regular interval, such as every 10 seconds. Consul's documentation spells out its rules for HTTP checks: "A 200-299 response code is healthy," a 429 (too many requests) is a warning, and "all other response codes indicate a failure." By default, a check that hasn't answered in 10 seconds counts as failed. An instance that fails is taken out of the list, and callers stop being sent to it. The load balancing chapter discusses what a good health check should test.
Shared configuration. Consul and etcd both include a key-value store, which is useful for configuration values and other data that many services share. Crumb could keep its search timeout or a feature switch under a key such as config/search/timeout_ms, and every search instance would read it from there. Consul's documentation says its key-value store's "main uses are storing configuration parameters and metadata," and warns that it "is not intended to be a full featured datastore."
The registry is one part of discovery. The other part is who reads it, the calling service or something in between. That gives the two styles below.
The calling service queries the registry itself, for example Netflix Eureka or HashiCorp Consul. It gets the list of healthy instances, and picks one using a load-balancing library inside its own process. Netflix's Ribbon library was the classic example. Spring Cloud LoadBalancer has since replaced it, and gRPC clients can do the same thing natively.
The calling service sends its request to one stable name or virtual IP address. Something else picks the instance: a load balancer or proxy that reads the registry.
An AWS Application Load Balancer in front of an autoscaling group works this way. It is a real extra network hop.
A Kubernetes Service works this way too. The name http://order-service/ resolves through the cluster's DNS to a stable virtual IP. On each node, kube-proxy watches the list of healthy pods. It programs kernel rules (iptables or IPVS) that rewrite packets sent to that virtual IP so they go to a real pod's address. No separate proxy process is involved, so the extra hop costs almost nothing.
Pros: application code makes plain HTTP calls to a name. There is nothing to embed and nothing language-specific.
Cons: with a real load balancer, there is one more hop and one more component to scale and keep available. Kubernetes' kernel-level balancing is also simple. It picks a backend once per connection, so long-lived HTTP/2 or gRPC connections can stay stuck to one pod.
As systems grew from a few services to hundreds, teams wanted the same behavior on every internal call:
Putting this in shared libraries means one library per language. Every fix, such as a TLS bug, then needs every service to be rebuilt and redeployed. A service mesh moves that logic out of the application and into a proxy.
In the classic design, each service instance gets its own sidecar proxy, which runs beside it in the same Kubernetes pod. Istio and Consul use Envoy as the sidecar. Linkerd uses its own lightweight proxy, written in Rust. A control plane (Istio's is called istiod) sends routing rules, policies and short-lived certificates to all the sidecars.
http://service-b/. Its sidecar picks a healthy instance of B, opens an mTLS connection to B's sidecar, applies the timeout and retry policy, and records metrics.Two limits to keep in mind:
traceparent, or Zipkin's x-b3-* headers) from each incoming request to the requests it makes. Istio's documentation says this explicitly.Most of a reverse proxy's benefits don't depend on having several servers. With one server behind it, the proxy still:
A load balancer, by contrast, only makes sense once there are several servers to spread requests across.
A load balancer exists to spread requests over several servers that do the same job, and to stop sending requests to servers that fail. A reverse proxy exists to be the single public face of your servers and to take shared work off them: SSL termination, compression, caching, static files and security filtering. It is useful even with one server.
Yes, one program can be both. NGINX and HAProxy can act as a layer 7 reverse proxy and a load balancer at the same time. In NGINX, listing a second server in an upstream block turns the reverse proxy into a load balancer too.
It adds complexity: one more component to configure, monitor, upgrade and debug, and a wrong setting on it affects every request. A single reverse proxy is also a single point of failure. If it goes down, every server behind it is unreachable.
To remove that, run two or more reverse proxies with failover. For example, an active one and a standby exchange heartbeats and share a virtual IP address, and the standby takes the address if the active one stops responding. That fixes the single point of failure but adds still more configuration and testing.
In the thread-per-connection model, every connection, even an idle keep-alive one, ties up a thread or process: its stack, its kernel bookkeeping, and a place in the scheduler. Memory and context-switching costs grow with the number of connections, not with the amount of work being done. Tens of thousands of mostly idle clients can exhaust the server.
An event-driven proxy runs about one worker per core. Each worker registers its non-blocking sockets with epoll or kqueue once, sleeps until the kernel reports which ones are ready, and services only those. An idle connection costs some kernel socket state and a small record in the proxy. NGINX quotes about 2.5 MB for 10,000 inactive keep-alive connections. The trade-off is that a worker must never block, since one slow operation stalls every connection it owns.
With a separate application (platform) layer, the website's web servers and the mobile app both call the same application servers. The new mobile traffic lands on the application layer, so Crumb adds application servers without adding web servers. Each layer can be scaled and configured for its own work, and the cache and database code lives in one place instead of being repeated for every client. Workers in the application layer can also take slow jobs off the request path through a queue.
Gains: each service (users, recipes, search, comments, photos) can be deployed on its own, so a search fix doesn't need the whole application rebuilt. Busy services can be scaled separately. A bug is more likely to break one feature than the whole site. Small teams can own their services end to end.
Costs: a different approach to architecture, operations and process; a build, deploy pipeline, monitoring and on-call owner per service; network calls that can fail, which need timeouts and safe retries; harder debugging across services; and data spread across services, which can no longer share one transaction. Uber's post describes engineers working through about 50 services across 12 teams to find the cause of one problem.
Each instance registers its service name, address and port. The registry then checks it regularly, often over HTTP: it calls an endpoint such as /health, and in Consul a 2xx response means healthy, a 429 is a warning, and anything else is a failure. Failed instances are removed from the list, so callers aren't sent to them. ZooKeeper works differently: an instance registers an ephemeral znode, which disappears when the instance's session ends.
Consul and etcd also include a key-value store. Teams use it for configuration values and other data that many services share, such as timeouts or feature switches.
It gives each kind of client (mobile app, web app) its own thin backend, owned by that client's team, instead of one general-purpose API for everyone. The BFF calls the core services, combines the results, and returns responses shaped for that client: one compact payload per screen for mobile, richer data for the web.
It solves two problems: clients making many round trips over slow networks, and core services filling up with presentation-specific changes requested by each frontend. The costs are more services to operate and a risk of logic being copied across BFFs, so business rules should stay in the core services.
First, the gateway doesn't strip an X-User-Id header sent by the client. An attacker sends their own, and it reaches the service unchanged. The gateway must remove or overwrite it.
Second, the services trust that header from anyone who can reach them. An attacker who gets onto the internal network, or finds a service exposed by mistake, can skip the gateway entirely. Services should accept the header only on connections authenticated as the gateway (mTLS is the usual way), or verify the token themselves.
In client-side discovery, the caller queries the registry and load balances in its own process. There is no extra hop, but every language needs a registry client and a balancing library. In server-side discovery, the caller sends to a stable name and a load balancer or proxy picks the instance. Clients stay simple, at the cost of an extra component and often an extra hop.
A Kubernetes Service is server-side discovery. The caller uses a stable DNS name and virtual IP and never sees the registry. The balancing is done by kernel rules that kube-proxy programs on every node, not by a separate proxy server, so there's almost no added hop. It balances per connection, though, so long-lived gRPC connections may all stick to one pod unless something balances per request.
It moves mTLS, certificate rotation, retries, timeouts, circuit breaking and per-call metrics out of the application into a separate proxy. Every service gets the same behavior whatever language it's written in. You upgrade or fix that behavior by rolling out a new proxy, without rebuilding every service.
It doesn't remove all the work from the application. Services still have to copy trace headers from incoming to outgoing requests for distributed traces to connect, and retries are only safe if the operations being retried are idempotent. It also adds a small latency cost on every hop and a proxy's worth of memory and CPU per pod.
istiod control plane.Previous: Load balancers · Next: Relational databases and scaling SQL