All articles
NetworkingNginxDevOpsLoad Balancing

The Layers of a Network Request: nginx stream vs HTTP Proxying

Emmanuel Nwanochie

·

2026-06-21

·

6 min read


I recently spent a frustrating afternoon trying to get nginx to round-robin traffic across two backend services. What looked like a five-minute config change turned into a proper rabbit hole — and by the end of it I understood a lot more about which layer of the network stack I was actually working at. This is a write-up of what I learned, because the distinction between proxying at the HTTP layer and load-balancing at the TCP layer is one of those things that's obvious in hindsight but genuinely confusing in the moment.

The problem

I had two instances of a service running and I wanted nginx to distribute incoming connections evenly between them — a classic round-robin setup. My first instinct was the thing every nginx tutorial shows you: define an upstream block and proxy_pass to it inside a server block. It seemed right. It wasn't — at least not for what I was doing — and chasing down why sent me back to basics on how a network request is actually layered.

What pushed me into the investigation was TLS. Both backend services terminate their own TLS — each presents its own certificate and completes the handshake itself — and I needed the client's TLS session to reach the backend untouched: end-to-end TLS. My instinct was an HTTP (Layer 7) reverse proxy. To be clear, an HTTP proxy can talk to HTTPS backends — nginx terminates the client's TLS and opens a separate TLS connection to the backend with proxy_pass https://backend. But that's TLS termination and re-encryption at the proxy, not passthrough: nginx becomes the TLS endpoint the client actually talks to, so it needs a certificate for the client-facing name, and the backend's own certificate is no longer what the client sees. For my setup that broke the requirement — I didn't want nginx to be the TLS endpoint at all. The real mismatch wasn't certificates "fighting"; it was that I needed passthrough where an HTTP proxy gives you termination. That's what sent me back to basics on how a request is actually layered.

A request travels through layers

When your client talks to a server, the data doesn't go straight across in one piece. It's wrapped and unwrapped through a stack of layers, each responsible for a different concern. The simplified mental model I keep coming back to:

  • Layer 7 — Application: HTTP, gRPC, WebSocket. This is where URLs, headers, methods, cookies, and status codes live. To operate here, something has to actually parse the HTTP protocol.
  • Layer 4 — Transport: TCP and UDP. This layer only knows about ports, connections, and raw byte streams. It has no idea whether the bytes flowing through are HTTP, a database protocol, or video.
  • Layer 3 — Network: IP. Routing packets between addresses across networks.
  • Layers 2 & 1 — Link & Physical: MAC addresses, frames, and the actual wire/radio carrying the bits.

The key insight: where you intercept traffic determines what you can see and do with it. A proxy that operates at Layer 7 can read and rewrite headers, route by path, terminate TLS, and inspect the request body — but it pays the cost of fully parsing every request. A load balancer at Layer 4 just shuttles bytes between a client connection and a backend connection. It's faster and protocol-agnostic, but it's blind to anything above TCP.

nginx works at two different layers

This is the part that finally made it click for me. nginx has two separate top-level contexts, and they live at different layers of the stack:

  • The http {} context is a Layer 7 reverse proxy. proxy_pass here speaks HTTP — it parses requests, can route by host/path, and manipulate headers.
  • The stream {} context is a Layer 4 proxy. It load-balances raw TCP/UDP connections without understanding what protocol is inside them.

Round-robin at the HTTP layer (Layer 7)

http {
    upstream backend {
        server 10.0.0.1:8080;
        server 10.0.0.2:8080;
    }

    server {
        listen 80;
        location / {
            proxy_pass http://backend;
        }
    }
}

Round-robin is the default here, so nginx spreads individual HTTP requests across the servers — even multiple requests sent over the same client connection can land on different backends, because nginx parses each request. This is perfect when your backends speak HTTP and you want HTTP-aware features.

Round-robin at the TCP layer (Layer 4)

stream {
    upstream backend {
        server 10.0.0.1:9000;
        server 10.0.0.2:9000;
    }

    server {
        listen 9000;
        proxy_pass backend;
    }
}

Notice there's no location and no http:// scheme — at this layer there is no notion of a URL or an HTTP request. nginx accepts a TCP connection and forwards the byte stream to one of the upstreams, round-robin by default. One important nuance: because Layer 4 is connection-oriented, this balances per TCP connection, not per HTTP request. If a client holds a single keep-alive connection open, every request riding that connection goes to the same backend. That's usually fine for connection-level distribution, but it's a genuine difference from Layer 7 — and worth knowing before you reach for it.

Why I ended up using stream instead of HTTP proxying

Once I understood the two contexts, the choice was clear. An HTTP-layer proxy would have to terminate the client's TLS and re-encrypt to the backend — perfectly workable in general, but exactly the model I needed to avoid. What I actually wanted was connection-level passthrough: accept each incoming TCP connection and hand the raw bytes to one of the backends, without nginx ever becoming the TLS endpoint.

That's what stream gives you. At Layer 4 nginx forwards the encrypted bytes straight through, so the TLS handshake is end-to-end between the client and whichever backend the connection lands on. Each backend keeps presenting its own certificate directly to the client, and nginx needs no certificate of its own. The tradeoff I accepted in return: balancing is per connection rather than per request, and nginx is blind to everything above TCP — no path routing, no header rewriting, no caching.

By dropping to Layer 4 I got clean per-connection round-robin, lower overhead, and TLS passthrough that matched what the architecture actually required. The lesson wasn't "stream is better than http" — it was that I'd been reaching for the wrong layer for the job.

How to know which layer you want

A quick heuristic from this experience:

  • Use the HTTP layer (http {}, Layer 7) when you need per-request balancing, routing by path or host, TLS termination at the proxy, header manipulation, caching, or anything that requires understanding the request.
  • Use the TCP/stream layer (stream {}, Layer 4) when you need end-to-end TLS passthrough (the backend, not the proxy, is the TLS endpoint), when the protocol isn't HTTP, or when you want minimal overhead and protocol transparency — just remember it balances per connection, not per request.

What started as a config headache turned into one of the more useful things I've internalized this year. Network requests are layered for a reason, and a surprising number of "why won't this work" problems are really "I'm operating at the wrong layer" problems in disguise. If you've run into something similar, I'd love to compare notes — reach out through the contact section of this site.


More articles

Building CaricatureCam: Real-Time Face Warping in the Browser

How I built a browser-based app that applies real-time facial caricature effects to your webcam at 30+ FPS — entirely on-device, using MediaPipe, React, and a pluggable effects architecture.

Automating Linux User Creation with Bash Scripts

Introduction Managing users on a Linux system can be a repetitive and error-prone task, especially when dealing with a large number of users. Automating this…

Handy Javascript Array Methods

There are really handy array methods in javascript to keep in mind when trying to manipulate data within an array to get your desired output. I would be going…

© 2026 Emmanuel Nwanochie

Built with Next.js & Chakra UI