The traffic cop pattern
You are sitting between a client and a backend service. The client sends a request to your port. You need to forward that request to another server, wait for the answer, and pipe it back without the client knowing the difference. Rewriting the HTTP stack from scratch is unnecessary. Go ships with a complete reverse proxy implementation in the standard library. You hand the traffic to httputil.ReverseProxy, configure the target, and let the runtime handle connection pooling, header rewriting, and body streaming.
What a reverse proxy actually does
A reverse proxy acts as a transparent intermediary. Think of it like a mailroom clerk. Employees do not walk to the post office themselves. They hand their letters to the clerk, who knows exactly which outgoing bin to drop them in. When a reply arrives, the clerk reads the return address and delivers it to the correct desk. The employees never see the postal network. In Go, the clerk is a struct that implements the http.Handler interface. That interface requires a single method: ServeHTTP(http.ResponseWriter, *http.Request). Because the proxy implements that method, it plugs directly into http.ListenAndServe or any third-party router. The proxy intercepts the incoming request, constructs a new request pointing at the upstream server, copies the headers, streams the body, and writes the response back to the original client.
The five-line proxy
Here is the smallest working proxy. It forwards everything from localhost to a target URL.
package main
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
)
func main() {
// Parse the upstream address once at startup to avoid runtime panics
target, err := url.Parse("https://example.com")
if err != nil {
log.Fatal(err)
}
// Create the proxy handler pointing at the parsed URL
proxy := httputil.NewSingleHostReverseProxy(target)
// Start listening on port 8080 and block until the process exits
log.Fatal(http.ListenAndServe(":8080", proxy))
}
Run the file with go run main.go. Open http://localhost:8080 in a browser. The response from example.com flows through your process and back to the browser. The proxy handles the TCP handshake, TLS negotiation, and HTTP framing automatically. Keep the target URL static at startup. Parsing URLs on every request wastes CPU cycles and introduces unnecessary error paths.
Step by step through the request
When a request arrives, the runtime calls proxy.ServeHTTP. The method does not copy the entire request into memory. It streams the body directly from the client connection to the upstream connection. This keeps memory usage flat even when clients upload large files.
The proxy first creates a fresh http.Request struct. It copies the method, URL, headers, and body reader from the original request. It then runs a Director function. The default director rewrites the Host header to match the target URL and strips hop-by-hop headers like Connection and Keep-Alive. Those headers are specific to a single TCP connection and would confuse the upstream server if forwarded blindly.
Next, the proxy dials the upstream server. It uses an http.Transport instance under the hood. The transport manages a pool of idle TCP connections, handles TLS certificate verification, and respects keep-alive settings. If the upstream server supports HTTP/2, the transport negotiates the upgrade automatically. The proxy writes the request to the upstream connection and starts reading the response.
Once the upstream server replies, the proxy copies the status code and headers to the original http.ResponseWriter. It then streams the response body back to the client using io.Copy. The copy runs in a loop, reading chunks from the upstream connection and writing them to the client connection. When the upstream closes its side of the stream, the proxy closes the client side. The entire flow happens without buffering the full payload in RAM.
Adding real-world plumbing
Production proxies need timeouts, header rewriting, and error handling. The standard library gives you hooks for every stage of the request lifecycle. Here is a production-ready setup.
package main
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"time"
)
// NewProxy builds a reverse proxy with timeouts and header rewriting
func NewProxy(targetURL string) http.Handler {
target, err := url.Parse(targetURL)
if err != nil {
log.Fatalf("invalid upstream URL: %v", err)
}
proxy := httputil.NewSingleHostReverseProxy(target)
// Configure transport with connection limits and timeouts
proxy.Transport = &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
}
// Rewrite headers before the request leaves your process
proxy.Director = func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.Host = target.Host
req.Header.Set("X-Forwarded-For", req.RemoteAddr)
}
// Modify the upstream response before it reaches the client
proxy.ModifyResponse = func(resp *http.Response) error {
resp.Header.Set("X-Proxy-Handled", "true")
return nil
}
return proxy
}
func main() {
handler := NewProxy("https://api.example.com")
log.Fatal(http.ListenAndServe(":8080", handler))
}
The Director function runs before the request leaves your process. It is the standard place to rewrite paths, inject authentication tokens, or attach forwarding headers. The ModifyResponse function runs after the upstream replies but before the data reaches the client. Use it to strip sensitive headers, inject CORS policies, or rewrite cookies. The http.Transport configuration controls how your process talks to the upstream server. Setting MaxIdleConns prevents connection exhaustion under load. Setting TLSHandshakeTimeout stops your process from hanging when the upstream server is unreachable.
Context propagation and cancellation
Go's context.Context type carries deadlines, cancellation signals, and request-scoped values across API boundaries. The proxy automatically attaches the incoming request's context to the outgoing request. When a client cancels a request or closes the browser tab, the context fires a cancellation signal. The proxy detects the signal and aborts the upstream dial. This prevents your process from wasting resources on abandoned requests.
If you build custom middleware around the proxy, always pass the context as the first parameter. Name it ctx. Functions that accept a context should respect cancellation and deadlines. The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not swallow errors or wrap them in silent recoveries. Let the error bubble up to the server's error handler.
Where things go sideways
Proxies introduce failure points that simple handlers do not. The most common issue is forgetting to close request or response bodies. Go does not garbage collect network connections automatically. If you read from a body and discard it without calling body.Close(), the underlying TCP connection leaks. The runtime eventually runs out of file descriptors and starts failing with too many open files. Always defer resp.Body.Close() immediately after receiving a response.
Header forwarding trips up many developers. The Host header must match the upstream server's expected domain, or the upstream will reject the request with a 400 Bad Request. The default Director handles this, but custom directors often forget to set req.Host. If you forget to parse the target URL correctly, the compiler rejects the program with cannot use target (type string) as type *url.URL in argument to httputil.NewSingleHostReverseProxy. Always parse URLs at startup and handle the error.
TLS verification is another trap. If your upstream server uses a self-signed certificate, the proxy will refuse to connect. The error surfaces at runtime as x509: certificate signed by unknown authority. You can bypass verification by setting tls.Config{InsecureSkipVerify: true} on the transport, but only do this in isolated test environments. Production systems should load the correct CA bundle.
Goroutine leaks happen when the proxy waits on a channel or context that never cancels. The http.Server creates a new goroutine for every incoming request. If your handler blocks indefinitely, the goroutine stays alive. The worst proxy bug is the one that never logs. Add a logging middleware that records the upstream status code and latency. When traffic drops to zero but memory usage climbs, you have a leak. Trust gofmt to keep your code readable. Argue logic, not formatting.
When to reach for a proxy
Use httputil.ReverseProxy when you need a transparent HTTP forwarder with automatic header rewriting and connection pooling. Use a custom http.Handler that manually dials upstream when you only need to forward specific paths or transform payloads on the fly. Use a dedicated proxy server like Nginx or Traefik when you need advanced caching, rate limiting, or TLS termination at scale. Use plain http.Get or http.Post when you are building a client that calls an API, not a transparent proxy.