System Design Primer · llms.txt
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
On your laptop, a client talks straight to your application process on port 8080. In production you almost never let internet clients do that. Exposing every backend service directly means each one has to terminate TLS, check credentials, enforce rate limits and survive whatever the internet throws at it, and clients have to know the address of every service.
Instead, requests come in through a front door: a reverse proxy, or its more application-aware relative, an API gateway. Behind it, services in a microservice system need a way to find each other as instances come and go, and something has to handle encryption, retries and metrics on every internal call.
The primer covers reverse proxies and microservices briefly. This chapter fills in the parts that come up in design discussions: why event-driven proxies like NGINX and Envoy can hold so many connections, what an API gateway adds on top of a reverse proxy, the Backend-for-Frontend pattern, the two styles of service discovery, and how sidecar proxies grew into the service mesh.
It builds on the primer's reverse proxy, application layer, load balancer and security chapters.
A proxy is a server that makes requests on someone else's behalf. The two kinds differ in whose behalf that is.
api.example.com and never learn which server answered. The proxy can terminate TLS, spread load across servers, cache responses, compress them, and hide the internal layout of the system.The same software often does both jobs. What makes it forward or reverse is where it sits and who configured it.
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 how servers used the operating system. The answer shaped every modern proxy, and it's why NGINX, and later Envoy, took over the front-door role from the classic Apache setup.
The traditional model gives each connected client its own operating system thread, or in Apache's old prefork mode, its own process. The thread reads the request, runs the handler, waits on the database, writes the response, and then waits for the client's next request on the same keep-alive connection.
Code written this way is easy to follow, but it scales poorly 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 blocking 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), saying which events it cares about, such as "readable" or "writable".epoll_wait, which sleeps until at least one registered socket is ready.The cost of an idle connection drops to some kernel socket state and a small per-connection structure in the proxy. The NGINX project's own figure is that 10,000 inactive keep-alive connections take about 2.5 MB of memory, not counting the kernel's socket buffers. That's why one modest machine running NGINX or Envoy can hold tens or hundreds of thousands of mostly idle connections.
The catch is that nothing in a worker may block. A slow disk read or a CPU-heavy computation stalls every connection on that worker. Proxies are a good fit because their work is mostly moving bytes between sockets. Apache itself later added the event MPM, which hands idle keep-alive connections to an event loop for the same reason.
Every API gateway is a reverse proxy, but not every reverse proxy is an API gateway. The line between them is blurry, and products sit all along it: Envoy is used as a plain proxy, a sidecar and a gateway, and 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/TCP, cache, compress. | Manage APIs as a product: who may call what, how often, in what shape. |
| Configuration | Mostly files, applied with a graceful reload. | Usually changed through an API or control plane, often per route or per consumer. |
| Authentication | TLS, basic auth, maybe a call out to an auth service. | Validating API keys, OAuth 2.0 access tokens and JWTs, checking scopes. |
| 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. | Rewriting requests and responses, JSON to gRPC, request validation against a schema. |
| Observability | Access logs and basic metrics. | Per-consumer analytics, distributed tracing, usage reports for billing. |
In a microservice system, the gateway is the single entry point for external traffic. A typical request passes through these steps:
Without a gateway, every service has to validate tokens itself: parse JWTs, fetch and rotate the identity provider's public keys, and reject bad requests. Doing it in a dozen services written in different languages invites a dozen slightly different bugs.
With a gateway:
X-User-Id: 104 header, or by forwarding the verified token itself.There's a catch with step 3. The gateway must remove any X-User-Id header the client sent, or a caller could claim to be anyone. And services must accept that header only from the gateway, which is one reason internal traffic is authenticated with mTLS. Many teams also let each service check the token again, so a service exposed by mistake isn't wide open. Security 101 covers tokens and mTLS in detail.
Browsers and third-party developers want HTTP with JSON. Many internal services prefer gRPC: it uses Protocol Buffers (a compact binary format) over HTTP/2, and its contracts are typed and generated from a schema.
A gateway can bridge the two. A client sends GET /v1/users/104, and the gateway turns it into a GetUser gRPC call with user_id = 104, then converts the binary reply back to JSON. This is called gRPC-JSON transcoding. The mapping is declared in the service's .proto file with HTTP annotations, and Envoy, Google Cloud's API gateways and others implement it. External clients get a normal REST API while internal services stay gRPC, and either side can change its internals without the other noticing.
A related option, gRPC-Web, lets browser code call gRPC services through a proxy that 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 layer, owned by the team that builds that client:
Each BFF changes at the pace of its own frontend, and the core services don't fill up with "add this field for the iOS app" changes. The costs are more services to run, and logic that can be duplicated between BFFs if you aren't careful. Keep business rules in the core services and only aggregation and shaping in the BFF.
A public API for third-party developers is usually a separate product with its own versioning, API keys and quotas, often served by the API gateway directly rather than by a BFF.
On a fixed set of servers, a service can reach its database at a known hostname like 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 the checkout service find a healthy instance of the order service right now? Every approach has a service registry, a list of the live instances of each service, kept current by registration and health checks. The approaches differ in who reads it.
The calling service queries the registry itself, for example Netflix Eureka or HashiCorp Consul, gets the list of healthy instances, and picks one using a load-balancing library in 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, and 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, and it is a real extra network hop.
A Kubernetes Service works this way too. http://order-service/ resolves through cluster DNS to a stable virtual IP. On each node, kube-proxy watches the list of healthy pods and programs kernel rules (iptables or IPVS) that rewrite packets for that virtual IP to a real pod address. No separate proxy process is involved, so the extra hop is close to free.
Pros: application code just makes plain HTTP calls to a name. Nothing to embed, nothing language-specific.
Cons: with a real load balancer, one more hop and one more component to scale and keep available. Kubernetes' kernel-level balancing is also simple: it picks a backend per connection, so long-lived HTTP/2 or gRPC connections can stick 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, and every fix, a TLS bug for example, needs a rebuild and redeploy of every service. 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, running beside it in the same Kubernetes pod. Istio and Consul use Envoy as the sidecar. Linkerd uses its own lightweight Rust proxy. A control plane (Istio's is called istiod) pushes routing rules, policy 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 inbound request to the outbound requests it makes. Istio's documentation says this explicitly.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, so tens of thousands of mostly idle clients 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 structure 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.
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 duplicated 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, so 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, so 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: no extra hop, but every language needs a registry client and balancing library. In server-side discovery, the caller sends to a stable name and a load balancer or proxy picks the instance: simple clients, 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 identical behavior whatever language it's written in, and you upgrade or fix that behavior by rolling out a new proxy, not by rebuilding every service.
It doesn't remove all the work from the application. Services still have to propagate trace headers from inbound to outbound 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: Application layer · Next: Database