---
title: Reverse Proxy
description: HTTP/1.1 reverse proxy middleware with round-robin load balancing, path filtering, automatic failover, and built-in keep-alive connection pooling.
---
`ReverseProxy` is a [`Middleware`](/building-apps/middleware/) that forwards incoming HTTP/1.1 requests to one or more backends. It lives in `src/proxy/mod.rs` and requires no feature flags.
## Basic usage
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
// All requests forwarded round-robin across two backends.
let app = App::new()
.wrap(ReverseProxy::new(["http://backend-1:8080", "http://backend-2:8080"]));
```
## Selective proxying with `path_prefix`
By default every request is proxied. Use `.path_prefix()` to restrict proxying to a specific path prefix; all other requests fall through to the inner application.
```rust
// Only proxy /api/* — other paths are handled locally.
let app = App::new()
.wrap(ReverseProxy::new(["http://api-service:3000"])
.path_prefix("/api"));
```
## Load balancing strategy
`ReverseProxy` accepts a `.strategy()` builder for future extensibility. The only active strategy is round-robin.
```rust
use rust_web_server::proxy::{LoadBalancing, ReverseProxy};
let proxy = ReverseProxy::new(["http://a:8080", "http://b:8080"])
.strategy(LoadBalancing::RoundRobin); // default; explicit for clarity
```
The counter is a lock-free `AtomicUsize` incremented on every request. The backend index is `counter % backend_count`, giving a uniform cyclic distribution with no mutex overhead.
## Timeouts
```rust
let proxy = ReverseProxy::new(["http://backend:8080"])
.connect_timeout_ms(3_000) // TCP connect timeout (default: 5 000 ms)
.read_timeout_ms(60_000); // Response read timeout (default: 30 000 ms)
```
The write timeout for the forwarded request is always 10 seconds (not configurable via the builder today).
## Automatic failover
When a backend connection fails, `ReverseProxy` tries the next backend in round-robin order. Only after all backends have failed does it return `502 Bad Gateway`.
```
request → backend-1 fails → backend-2 fails → 502 Bad Gateway
request → backend-1 fails → backend-2 OK → response forwarded
```
## Headers
### Hop-by-hop headers stripped
The following headers are never forwarded to the upstream or back to the client, per RFC 7230:
- `Connection`
- `Keep-Alive`
- `Proxy-Authenticate`
- `Proxy-Authorization`
- `TE`
- `Trailer`
- `Proxy-Connection`
- `Transfer-Encoding`
- `Upgrade`
- any header named in the client's `Connection` header
- `Content-Length` (regenerated from the body that is actually sent)
### Headers added to forwarded requests
| Header | Value |
|--------|-------|
| `X-Forwarded-For` | Client IP from `ConnectionInfo`, appended to an incoming `X-Forwarded-For` (`203.0.113.7, 10.0.0.1`) — like nginx's `$proxy_add_x_forwarded_for`; only trust the entries your own proxies added |
| `Via` | `1.1 rws` |
| `Host` | Backend host (replaces the original `Host` header) |
| `X-Forwarded-Host` | The `Host` the client sent, unless it already carries an `X-Forwarded-Host` (since 18.8.4) |
| `X-Forwarded-Proto` | `https` when the client connected over TLS (HTTPS, HTTP/2 over TLS, HTTP/3), else `http`; an incoming `X-Forwarded-Proto` is kept, so rws behind another TLS terminator passes its answer on (since 18.9.0) |
| `Connection` | `keep-alive` (pooled path) or `close` (non-pooled callers) |
| `Content-Length` | Length of the forwarded body (exactly one) |
## Connection pooling
`ReverseProxy` ships with a built-in `ConnPool` that reuses idle TCP connections to backends (`https://` backends share a process-wide pool of TLS connections). When a backend responds with `Connection: keep-alive` and a fixed-size body (`Content-Length`), the stream is returned to the pool and reused for the next request to the same backend.
Streaming responses (SSE, chunked, large downloads) are forwarded directly to the client without buffering — see [Streaming responses](#streaming-responses).
**Defaults:** 64 idle connections per backend, 60-second idle timeout. No configuration needed.
### Tuning the built-in pool
```rust
use rust_web_server::proxy::ReverseProxy;
let proxy = ReverseProxy::new(["http://backend:8080"])
.max_idle_conns(32); // up to 32 idle connections per backend
```
### Sharing a pool across proxy instances
Useful when two `ReverseProxy` instances route to overlapping backends (e.g., an API proxy and an auth proxy both reach the same service).
```rust
use std::sync::Arc;
use std::time::Duration;
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::{ConnPool, ReverseProxy};
let pool = Arc::new(ConnPool::new(32, Duration::from_secs(120)));
let api = App::new().wrap(
ReverseProxy::new(["http://api:3000"]).with_pool(Arc::clone(&pool)));
let auth = App::new().wrap(
ReverseProxy::new(["http://auth:4000"]).with_pool(Arc::clone(&pool)));
```
### How pooling works
| Backend response | Pool action |
|-----------------|-------------|
| `Connection: keep-alive` + `Content-Length` ≤ 1 MB | Stream returned to pool after body is read |
| `Transfer-Encoding: chunked` | Streamed to client (raw passthrough); connection not pooled |
| `Content-Type: text/event-stream` | Streamed to client (chunked re-encoding); connection not pooled |
| `Content-Length` > 1 MB | Streamed to client; connection not pooled |
| `Connection: close` or EOF | Stream dropped; TCP connection closed |
| Idle stream older than `idle_timeout` | Evicted on next `acquire()` |
| Pool at `max_idle` for this backend | Extra stream dropped (TCP closed) |
:::tip
Connection pooling is most impactful when backends and the proxy share a network (low-latency link). On high-throughput routes, pooling eliminates per-request TCP handshake latency entirely.
:::
## Backend URL format
Backend strings accept any of these forms:
```
https://host:port # TLS, verified against the WebPKI roots; port defaults to 443
http://host:port # scheme stripped, plain TCP
h2://host:port # treated as plain TCP
host:port # bare host:port
host # port defaults to 80
```
:::note[TLS to upstream]
`https://` backends are reached over TLS (rustls), with the certificate
verified against the WebPKI root store — the CA bundle browsers use. TLS
connections are pooled like plain ones, so the handshake is paid once per
connection, not per request. For a backend with a private-CA or
self-signed certificate, trust that CA instead of the public roots:
```rust
let proxy = ReverseProxy::new(["https://billing.internal:8443"])
.tls_ca_file("/etc/rws/internal-ca.pem")?; // PEM; replaces the WebPKI roots
```
A backend that requires a client certificate (mutual TLS) gets the one you
give it (since 18.3.0):
```rust
let proxy = ReverseProxy::new(["https://billing.internal:8443"])
.tls_client_cert("/etc/rws/client.pem", "/etc/rws/client.key")?; // PEM chain + private key
```
:::
## Streaming responses
`ReverseProxy` detects streaming backend responses and forwards bytes to the client as they arrive, without buffering the full body in memory. A response is streamed when any of these conditions are true:
| Condition | Use case |
|-----------|----------|
| `Content-Type: text/event-stream` | SSE / Server-Sent Events |
| `Transfer-Encoding: chunked` | AI token streams (OpenAI, Anthropic, etc.) |
| `Content-Length` > 1 MB | Large file downloads |
### How it works
1. The proxy reads only the response headers from the backend.
2. It creates a `Response` with the parsed headers and sets `Response::stream_pipe` to a reader backed by the live backend TCP connection.
3. The server detects `stream_pipe` and calls `Server::pipe_stream()`:
- **Chunked backend**: raw chunk frames are forwarded byte-for-byte (client handles decoding).
- **SSE / plain body**: bytes are re-encoded as `Transfer-Encoding: chunked` so the client receives each fragment immediately.
Streamed connections are never returned to the `ConnPool` — the backend socket is consumed by the pipe.
### SSE through the proxy
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
// All SSE responses from the backend are forwarded without buffering.
let app = App::new()
.wrap(ReverseProxy::new(["http://event-service:9000"])
.path_prefix("/events"));
```
The client connects with `Accept: text/event-stream` as usual. The proxy transparently forwards each SSE event as a chunked frame the moment it arrives from the backend.
### AI token stream through the proxy
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
// OpenAI / Anthropic APIs send Transfer-Encoding: chunked.
// The proxy forwards each token chunk without waiting for the full response.
let app = App::new()
.wrap(ReverseProxy::new(["https://api.openai.com"])
.path_prefix("/v1"));
```
### Custom stream_pipe (application code)
Any application handler can use `stream_pipe` to stream arbitrary `Read` sources:
```rust
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
fn my_handler(_req: &_, _params: &_, _conn: &_, _state: &_) -> Response {
let mut r = Response::new();
r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
// Any std::io::Read + Send value works: files, cursors, sockets, generators …
r.stream_pipe = Some(Box::new(std::io::Cursor::new(b"data: hello\n\n".to_vec())));
r
}
```
## HTTP/3 backends
`H3ReverseProxy` forwards to `https://` backends over HTTP/3 (QUIC), with the
same builder methods as `ReverseProxy` (since 18.13.0, `http3` feature —
the default):
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::H3ReverseProxy;
let proxy = H3ReverseProxy::new(["https://edge1.internal:443", "https://edge2.internal:443"])
.path_prefix("/api")
.tls_ca_file("/etc/rws/internal-ca.pem")? // or the WebPKI roots by default
.name("edge"); // the fallback metric's upstream label
let app = App::new().wrap(proxy);
```
One multiplexed QUIC connection per backend is reused across requests;
bodies stream both ways and trailers are kept. A backend QUIC can't reach is
sent the request over HTTPS on TCP and stays on TCP for 5 minutes,
counted by `rws_upstream_h3_fallbacks_total{upstream="edge"}`. In the config
file the same is `[[upstream]] protocol = "h3"` — see
[HTTP/3 upstreams](/proxy/config-driven/#http3-upstreams).
## 502 Bad Gateway
`ReverseProxy` returns `502 Bad Gateway` with `Content-Type: text/plain` when:
- No backends are configured.
- All backends fail to connect or return a network error.
## Combining with other middleware
Because `ReverseProxy` implements `Middleware`, you can stack it with rate limiting, auth, rewriting, and any other middleware via `.wrap()`.
```rust
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::proxy::ReverseProxy;
use rust_web_server::rate_limit::RateLimitLayer;
let app = App::new()
.wrap(RateLimitLayer::new(100, 60)) // 100 req/min per IP
.wrap(ReverseProxy::new(["http://backend:3000"])
.path_prefix("/api"));
```
Middleware is applied outermost-first, so `RateLimitLayer` runs before `ReverseProxy`.