Get E-Book
HTTP Servers, Clients & Proxies

Reverse Proxies, Static Files, and Streaming Bodies

Ishtmeet Singh @ishtms/June 9, 2026/45 min read
#nodejs#http#proxies#streams#static-files

Most of the time, a client never talks to your application at all. It talks to a reverse proxy, and the proxy talks to your application on the client's behalf.

So two HTTP requests are in play, not one. The client sends a request to the proxy. The proxy sends a separate request to the origin server. The origin server is whatever HTTP server ends up handling the resource once routing has settled the host, path, and method. In a Node deployment that origin is usually your own process, even though the public client never opens a connection straight to it.

The proxy itself runs the same sequence every time. It reads the client request, applies its routing and forwarding rules, opens a fresh upstream connection or reuses one it already has, sends a new request to the origin, then copies the response back to the client. Each of its HTTP connections keeps its own rules the whole time.

Chapter 9 covered the TCP side of this. socket.remoteAddress tells your backend who the immediate TCP peer is, and behind a reverse proxy that peer is the proxy. HTTP stacks headers, request targets, forwarding metadata, and body streaming on top of that single transport fact.

In a reverse-proxy setup, traffic runs one direction.

text
client
  -> reverse proxy
  -> origin server

What puts a reverse proxy in front of you is the deployment. A public DNS record, a load balancer listener, a platform router, a CDN, an ingress controller, or a local front process steers client traffic to the proxy before anything else sees it. The client aims at the public service endpoint. Your backend sees only the proxy as its connected peer.

A forward proxy is the client's choice instead. Client code, a browser, an agent, or a network policy points outbound requests at the proxy, because that client environment decided outbound traffic should pass through it.

The forward-proxy flow reads almost the same.

text
client
  -> forward proxy
  -> origin server

Reverse and forward proxies are both HTTP intermediaries, and either one can change what each endpoint sees. Code tends to go wrong when it loses track of who put the proxy in the path and which side it is protecting.

Proxy Roles

Start with the origin server, since everything ahead of it forwards toward it. The origin is the server that finally handles the selected HTTP resource. It might be a Node http.Server, a framework sitting on top of one, a static file server, an object storage endpoint, or some other HTTP service. Whatever it is, the origin is the process that turns a routed request into real application work.

Routing inside a reverse proxy is usually a deployment decision. The proxy might pick an origin from the public Host header, from the URL path prefix, from a configured upstream name, or from a local rule that sends /api to one process and /assets to another. Unless your Node process is itself the proxy, Node only ever sees the request the proxy already decided to send upstream.

Even a small reverse proxy is doing a genuine handoff. It reads bytes off one socket, parses a single HTTP request, and builds a second HTTP request on a different socket. The upstream request can carry a different Host header, ride a different keep-alive connection, follow a different timeout policy, and frame its body differently. The origin's response travels back through the proxy, which then writes its own response to the original client.

Clients usually send a reverse proxy origin-form requests.

http
GET /api/users/42 HTTP/1.1
Host: example.com

The client puts a path in the request line and names the public host in the Host header. The proxy already holds a routing table that says where traffic for example.com should go, and it builds the upstream request from the incoming request together with that table.

A forward proxy, handling ordinary HTTP, usually receives absolute-form request targets instead.

http
GET http://example.com/api/users/42 HTTP/1.1
Host: example.com

The request target includes the scheme and authority, because the proxy has to know the final destination. The forward proxy reads that destination out of the request, applies local policy, opens or reuses a connection to the origin server, and forwards the request along.

Those request-target forms came up in the HTTP wire-format chapter. A reverse proxy handler usually sees req.url start with /. A forward proxy handler can see req.url carry a full http://.... A router that runs both through one code path can send traffic to the wrong place.

Then there is the Host header. A reverse proxy may keep the original public host, because the origin routes by that public host. Or it may rewrite Host to the upstream service name, because the origin expects an internal name. You see both in production. Which one is right comes down to the agreement between proxy and origin.

Say a Node origin expects Host: api.internal. Give it exactly that. A different origin builds public links out of the original host, and it needs either the original Host value preserved or forwarded host metadata to rebuild those links.

Paths get rewritten too. A proxy can take /api/users/42 and forward /users/42, or it can pass the whole path straight through. The origin only ever sees the upstream request, so chasing a routing bug means looking at two values, the path the public client sent and the path the proxy forwarded.

Reverse proxies also tend to end one HTTP connection and open another. The client-to-proxy side might be keep-alive while the proxy-to-origin side is a connection that just opened, and the arrangement can also flip. Through all of it, parser state, connection reuse, timeouts, and per-connection headers stay scoped to each side on its own.

From there, the proxy has real HTTP work in front of it. It keeps the request method, the meaning of the target, and the body bytes intact. It works out which headers may travel onward and which apply only to the current connection. Bodies stream in both directions with backpressure preserved. Forwarded client metadata gets added, overwritten, or dropped. And when the same server also serves files off disk, it chooses between forwarding upstream and answering from disk before it commits to anything.

The proxy also turns failures into status codes. A refused upstream connection can become 502 Bad Gateway. An upstream that answers too slowly can become 504 Gateway Timeout. A client request the proxy rejects on its own might get a local 400, 413, or some other response before the origin hears a thing.

Underneath those status choices, the Node code stays small. Connect upstream, write the headers, stream the request body, read the upstream response, stream the response body, then close or reuse each connection according to its own state.

Forward Proxy Requests and CONNECT

One method forces a forward proxy off its normal path, and that is CONNECT.

CONNECT asks the proxy to open a raw TCP connection to the authority named in the request target. The target shows up in authority-form.

http
CONNECT api.example.com:443 HTTP/1.1
Host: api.example.com:443

The proxy reads api.example.com:443, opens a TCP connection to that host and port, and answers with a success response once the connection is up. After that response, the client and the target trade raw bytes straight through the proxy.

Those bytes flow through what is called a proxy tunnel. The HTTP parser handles the setup request, and once the tunnel is accepted, everything after it is raw stream data as far as the proxy is concerned.

TLS usually shows up inside this path, since HTTPS clients reach forward proxies through CONNECT. The client asks for a tunnel to port 443, and the TLS handshake then runs inside that tunnel, between the client and the destination. Chapter 11 covers the TLS mechanics. The proxy sees the CONNECT request and the target authority, and after the tunnel is up it just forwards opaque bytes.

The proxy still gets to apply policy before the tunnel opens. It can permit only certain ports, block private address ranges, demand proxy authentication, or turn away a malformed authority. All of that happens while the request is still plain HTTP.

Once the tunnel is accepted, the proxy is left holding a byte stream between two sockets. HTTP-level inspection is over for that connection, unless the proxy itself terminates the encrypted protocol. That termination work sits with TLS and platform design, covered later.

Node surfaces incoming CONNECT requests on http.Server through the "connect" event.

js
server.on('connect', (req, clientSocket, head) => {
  console.log(req.url);
  clientSocket.end();
});

Here req.url holds the authority-form target, something like api.example.com:443. The clientSocket argument is the connection from the client to the proxy. And head carries any bytes that arrived after Node finished parsing the CONNECT request. Real tunnel code would connect to the target and wire both sockets together; this version only shows the point where Node hands the tunnel request to JavaScript.

Those head bytes already belong to the tunnel stream, and they vanish if you ignore them. When the proxy connects to the target, it writes head to the target socket first, then pipes the rest of the client socket through. Leave it out and the first bytes after the CONNECT request are simply gone.

A client built on core http.request() gets its own "connect" event when a proxy answers a CONNECT request.

js
const req = http.request(proxyOptions);

req.on('connect', (res, socket, head) => {
  console.log(res.statusCode);
});

That client-side event fires when the proxy has answered the CONNECT request. A 200 usually means the tunnel is ready. From there, your code works the socket bytes directly, and the normal HTTP response body path is finished.

For ordinary HTTP through a forward proxy, the proxy keeps parsing HTTP messages as usual. For HTTPS over CONNECT, it parses only the tunnel setup request and its response. The encrypted protocol running inside the tunnel stays between the client and the destination.

A reverse proxy can still receive CONNECT if a client sends one, though most reverse proxies reject it or route it through explicit tunnel handling. Treat CONNECT as its own method path. Feed it into an ordinary app router and you usually get useless behavior, since there is no normal request body to parse once tunnel setup is done. Node hands you a separate event precisely because the socket lifecycle changes the moment the parser recognizes the method.

Node Built-In Proxy Support

Node v24 ships built-in proxy support for outbound clients. The broad feature landed in v24.5.0, and the v24 docs still mark it active development. The runtime helper http.setGlobalProxyFromEnv() arrived later, in v24.14.0. Treat all of this as current v24 behavior and recheck the docs when you upgrade.

This is the half that lets a Node process act as a client sitting behind a forward proxy. It can route http.request(), https.request(), and fetch() through proxies you configure from environment variables or from an explicit object. Reverse proxy behavior is still your own server-side code, accepting an inbound request, building an upstream request, and streaming the response back.

At process start, NODE_USE_ENV_PROXY=1 or --use-env-proxy tells Node to build the global HTTP agent from proxy environment settings. At runtime, http.setGlobalProxyFromEnv() updates the global HTTP agent, the HTTPS agent, and the Undici global dispatcher.

You can also set proxy routing up at runtime with explicit environment values.

js
import http from 'node:http';

const restore = http.setGlobalProxyFromEnv({
  http_proxy: 'http://proxy.internal:8080',
  no_proxy: 'localhost,127.0.0.1,.svc.internal',
});

The object you pass in is proxyEnv, and it uses the same field names as the proxy-related environment variables. The restore function it returns puts the global agent and dispatcher settings back the way they were.

Call this during bootstrap. If you call it while requests are already in flight, you can change outbound routing out from under code that is still using the old global agent or dispatcher.

That global reset reaches past core http. It also moves the default outbound route for fetch(), because Node fetch runs on Undici. A library that does nothing but call fetch() can start going through the proxy the moment the process runs http.setGlobalProxyFromEnv(). An enterprise runtime may want exactly that, though a test suite expecting direct loopback calls can be caught off guard.

HTTP_PROXY gives a proxy URL for HTTP requests, and HTTPS_PROXY gives one for HTTPS requests. The lowercase forms, http_proxy and https_proxy, take precedence when both cases are set. Proxy URLs can include http://, https://, and userinfo for proxy authentication. That authentication policy turns product-specific fast, so this chapter stays at the routing level.

NO_PROXY is the bypass list, naming destinations that should take a direct client connection rather than the configured proxy. Node v24 accepts exact hostnames, domain suffixes like .internal.example.com, wildcard domain patterns like *.internal.example.com, exact IP addresses, IP ranges, host-and-port entries, and a bare * to bypass proxying entirely. Entries are comma-separated. Here too, lowercase no_proxy wins over uppercase NO_PROXY when both are present.

NO_PROXY is matched against the request destination, while the proxy URL is configured separately. That split starts to count once a process calls both public and internal services. You can have HTTPS_PROXY=https://proxy.internal:8443 active while NO_PROXY=.svc.internal,localhost sends the internal calls straight out. When an internal request fails, the direct DNS and TCP path is what to examine. A failing public request, by contrast, usually traces to the proxy connection or the proxy-to-origin hop.

A custom http.Agent can take proxyEnv directly.

js
const agent = new http.Agent({
  proxyEnv: process.env,
});

That scopes proxy routing to requests made through that agent, with no reset of the global agent or dispatcher. Reach for the global API when the entire process should follow one outbound proxy policy, and for an explicit agent when the policy only applies to a single client path.

Unix domain socket requests ignore all of these proxy settings. A Unix socket target is already a local path, so an HTTP proxy URL has no remote host to choose.

When you are debugging, log the final request destination along with whether an explicit agent or the global proxy setup was in play. A request that lands on a proxy unexpectedly usually traces back to process-level setup, a lowercase environment variable shadowing the uppercase one, or a NO_PROXY entry that fails to match the host and port the client actually used.

Hop-by-Hop Headers

Copy headers from one hop to the next without thinking, and you break the proxy.

Some headers describe a single HTTP connection and nothing beyond it. Others describe the HTTP message itself, which keeps moving toward its final recipient. A reverse proxy holds at least two HTTP connections at once, client to proxy and proxy to origin, and each one carries its own connection-specific metadata.

Diagram contrasting hop-by-hop headers that stop at the proxy with end-to-end headers that pass through both connections.
A reverse proxy holds two separate connections. Hop-by-hop headers such as Connection and Transfer-Encoding stop at the proxy and are regenerated for the next hop. End-to-end headers such as Content-Type, ETag, and Cache-Control pass through both hops unchanged.

Connection is the header that controls this. Its value can list other header fields that apply only to the current connection.

Take this pair of headers.

http
Connection: keep-alive, x-local-state
X-Local-State: parser-7

The proxy has to drop both connection and x-local-state before it forwards the request. X-Local-State turned hop-by-hop the moment Connection named it. A fixed removal list handles the common fields, but the tokens listed inside Connection are exactly what a fixed list cannot know about ahead of time.

The common hop-by-hop fields are Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, and Upgrade. The proxy-specific authentication fields, Proxy-Authenticate and Proxy-Authorization, stay on the proxy hop as well. Proxy-Connection is non-standard, yet old clients and intermediaries still send it, so working proxy code strips it rather than passing confusion along to the next hop.

A basic removal set covers the fixed names.

js
const hopByHop = new Set([
  'connection', 'keep-alive', 'proxy-authenticate',
  'proxy-authorization', 'proxy-connection',
  'te', 'trailer', 'transfer-encoding', 'upgrade',
]);

Node lowercases the names in message.headers, which is why the set is written in lowercase. A proxy working from rawHeaders instead has to handle case on its own, since HTTP header names are case-insensitive.

The runtime half comes from reading Connection itself.

js
function stripHopByHop(headers) {
  const out = { ...headers };

  for (const name of String(headers.connection ?? '').split(',')) {
    const token = name.trim().toLowerCase();
    if (token) delete out[token];
  }

  for (const name of hopByHop) delete out[name];
  return out;
}

The function strips the fields Connection names, then strips the common fixed set. It hands back a shallow copy, so caller code can pass the result into http.request() or response.writeHead() without mutating req.headers. Header arrays and duplicate raw field lines take more careful handling than this; headersDistinct and rawHeaders give you the raw material when you reach that level of detail.

TE carries one subtlety. TE: trailers holds only for the current connection, and only when the sender also lists TE inside Connection. A proxy that forwards TE without that connection option rewrites the protocol contract for the next hop. For a small proxy, filtering it along with the rest of the hop-by-hop set is the safer default.

End-to-end headers travel onward unless the proxy has a specific reason to rewrite them. Content-Type, Cache-Control, ETag, Last-Modified, application headers, and most request metadata are aimed at the next HTTP recipient, and often at the final one. The split follows what each header is for. Connection-specific data is consumed at the current hop, while exchange metadata keeps going.

Transfer-Encoding is the one to watch most closely. The client-to-proxy request can be chunked. The proxy-to-origin request can be chunked too, or it can carry a Content-Length, or it can have no body at all. The proxy should pick its upstream framing from the body it is actually going to send.

Copying both Content-Length and Transfer-Encoding off a client request is wrong on its own. Copying either one after you have changed the body is wrong as well. When the proxy streams the original body untouched, Node can produce upstream chunked framing once the length is absent. When the proxy buffers and rewrites the body, it should set the new length or let Node frame the new stream.

Upgrade opens yet another connection-specific path. A request carrying Upgrade: websocket asks the current HTTP peer to switch protocols on the current connection once a response comes back. A reverse proxy can support that, but only by handling the upgrade event and the socket handoff itself. Ordinary forwarding code that copies Upgrade to the origin and then treats the response as normal HTTP will break, because after an upgrade the connection has stopped being an ordinary HTTP message stream.

Responses get the same treatment. The origin's Connection header describes the origin-to-proxy connection only. The proxy's response back to the client needs its own connection metadata, which Node usually generates from the server response state. Forward the status and the end-to-end headers, and strip the hop-by-hop fields.

One small test catches the whole thing. Send a request with Connection: x-test-hop and X-Test-Hop: remove-me, then assert the origin never sees x-test-hop. Send an origin response with that same pair, and assert the client never sees a copy. A plain removal list passes on the fixed names and still fails here.

The upstream request gets its own header object after filtering.

js
const upstream = http.request({
  hostname: 'origin.internal',
  port: 8080,
  method: req.method,
  path: req.url,
  headers: stripHopByHop(req.headers),
});

That object describes the proxy-to-origin request. The method and path fields carry the client request through unchanged for a simple reverse proxy. hostname and port pick the origin endpoint. headers is a fresh object for the upstream hop. Any path or host rewrite goes right here.

The Host header is usually the deliberate exception to copying things across as-is. http.request() can build a host header out of hostname and port, or the proxy can supply an explicit Host. Pick one. Send a public Host to an origin that routes by internal name and the route can miss; send an internal Host to an origin that builds public redirects and the URLs come out wrong. The proxy is responsible for that contract.

Upstream timeouts go on the upstream request, not the inbound one. A proxy might be accepting a client request, connecting to the origin, waiting for response headers, waiting for response body bytes, or waiting for its own writes to drain. Each of those waits sits on a specific side of the exchange. Keep the upstream timers separate from the inbound client timers, so a log line or status code points straight at the operation that stalled.

Request bodies force an early decision. GET and HEAD usually turn up without a body in backend APIs, but Node still hands you req as a stream, because HTTP allows bodies on plenty of methods. The proxy should work out, from the method, the headers, and local policy, whether it will forward the body, discard it, or reject the request before any body bytes head upstream.

Discarding a body still leaves cleanup to do. If the proxy sends a local error response and leaves the incoming body unread, the client socket can still have bytes queued behind that response. If the proxy means to keep the connection alive, those unread request bytes can wreck the next parse. Plenty of small proxies just destroy the client connection after rejecting a body-bearing request. More careful code drains up to a limit, then closes or reuses based on message.complete and parser state.

The proxy also needs its response plan ready before the first upstream byte lands. It can pass the origin status through, map certain upstream failures onto gateway statuses, or intercept specific responses. Once the client response headers are committed, that exchange is locked into whatever response was chosen. So proxy code tends to wire its error handlers before starting either pipeline, set every header before the first body write, and keep cleanup close to whichever stream can fail.

Forwarded Client Metadata

The forwarded address headers you read on the backend were written by the proxies standing in front of you.

X-Forwarded-For is the common non-standard field that carries the original client address through a chain of proxies. It usually holds a comma-separated list.

http
X-Forwarded-For: 203.0.113.10, 10.0.0.5

By convention the leftmost value is the original client address, and the values after it are proxy hops. That convention only pays off when you know which proxy wrote the header, which proxies appended to it, and which component cleared any untrusted incoming value at the edge.

Forwarded is the standardized version. It can carry parameters like for, by, host, and proto.

http
Forwarded: for=203.0.113.10;proto=https;host=example.com

It can also hold several elements when more than one proxy adds metadata. Its grammar is stricter than the X-Forwarded-* family, with quoting rules for IPv6 addresses and identifiers. Even so, plenty of deployments stick with X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto, since frameworks and platform routers support them so widely.

Treat both styles as request input that deployment policy controls. Any public client can send X-Forwarded-For unless the edge proxy clears it first. A backend that reads the first value without knowing which proxy wrote it has turned a client-controlled string into address data.

Trust-proxy policy is a topic for the security chapters. On the backend, socket.remoteAddress is the immediate TCP peer, and forwarded headers are HTTP metadata that the request path supplied.

The clean way to think about it is by who wrote each value. The edge proxy writes the first trusted value, and every trusted proxy after it appends its own. The backend decides which proxy peers count as trusted writers; everything past that is plain string parsing.

A reverse proxy at the edge can write fresh metadata.

js
const prior = req.headers['x-forwarded-for'];
const chain = [prior, req.socket.remoteAddress].filter(Boolean).join(', ');
headers['x-forwarded-for'] = chain;

The value added here is the peer address the proxy can see. If a trusted proxy upstream already added a value, the chain keeps it. An edge proxy taking traffic straight off the public internet may clear any incoming value before writing its own. That call comes from the deployment's trust policy rather than from Node's HTTP parser.

Protocol and host metadata are handled along the same lines. A proxy that terminates HTTPS at the edge and speaks plain HTTP to a Node origin may add X-Forwarded-Proto: https. A proxy fronting several public hosts for one backend may forward the original host. The backend can lean on those fields for URL generation, redirects, logging, or routing only once the deployment has said which proxy is allowed to set them.

Forwarded metadata shows up in your logs too. Record only req.socket.remoteAddress and a proxied deployment fills the log with proxy addresses. Record only X-Forwarded-For and you can end up storing client-supplied strings whenever the edge handles headers loosely.

While debugging, keep all three in view. The socket shows the immediate peer. HTTP carries the forwarded chain. The deployment config holds the trust decision that ties them together.

Streaming Proxy Bodies

The point of a streaming proxy is that it begins forwarding the body before the whole thing has arrived.

Four stream objects sit in the middle of this. The inbound request body arrives as an http.IncomingMessage, a Readable. The outbound upstream request is an http.ClientRequest, a Writable. The origin's response comes back as another IncomingMessage, and the client response is a ServerResponse, also a Writable. A proxy wires those pairs together.

At its smallest, that is two pipelines.

js
const upstream = http.request(target, upstreamRes => {
  res.writeHead(upstreamRes.statusCode, stripHopByHop(upstreamRes.headers));
  pipeline(upstreamRes, res, onDone);
});

pipeline(req, upstream, onDone);

One pipeline() forwards the origin response body down to the client. The other forwards the client request body up to the origin. Headers get handled before either body pipeline runs. The proxy filters the response headers before it sends them to the client, and it should filter the request headers before it builds target.

Header timing is unforgiving. The moment res.writeHead() runs, or the first body chunk goes out, the client response headers are committed. A later upstream error can still close the response, but it can no longer turn that response into a clean 502 once bytes are on the wire. A buffering proxy can hold off on committing response headers for longer. A streaming proxy commits the instant it decides to pass the upstream response through.

Backpressure is the whole reason to stream here. When the origin connection stops accepting more request bytes, upstream.write() begins returning false. pipeline() reacts by pausing reads from req. Node then eases off pulling body chunks through the HTTP request stream, and eventually TCP flow control tells the client side to slow down. Run the client receiving a slow origin response and the same thing happens in reverse.

A lot of buffers are in play at the same moment. JavaScript streams hold high-water marks, OutgoingMessage tracks its writable state, and the HTTP parser tracks current body state. Below that, libuv watches socket readiness, the kernel keeps send and receive buffers, and TCP carries flow-control state. A streaming proxy works because pressure can travel back through all of those layers, instead of the whole body piling up in JavaScript memory.

Follow the request body while the origin is slow. Its socket write buffer fills, so ClientRequest starts recording pending bytes and the writable stream crosses its high-water mark. pipeline() then stops reading from the inbound IncomingMessage, the inbound parser hands JavaScript fewer body chunks, and the client socket's receive side drains more slowly. The kernel advertises a smaller receive window back to the client, and the client's upload slows down, because the proxy-to-origin side is the slower side.

Two lane flow of reverse proxy streaming backpressure for the request and response paths.
Each direction pairs a Readable with a Writable. Data flows forward while the backpressure signal travels backward, where a full write returns false, reads pause, and the slow side receive window shrinks. The request and response paths mirror each other.

Backpressure on the response side works the other direction, with the public client setting the pace. It reads slowly, ServerResponse backs up, and pipeline(upstreamRes, res) pauses the upstream response stream. Node reads less eagerly from the origin socket, the origin's send buffer can fill, and the origin server starts seeing backpressure on its own response writes. A proxy that keeps this sequence intact can move very large bodies on stable memory.

Because headers are parsed before body streaming starts, a proxy can reject a request before forwarding any body, as long as policy only needs the headers. Once it does start streaming upstream, the origin may receive bytes before the proxy has even seen the end of the client upload. A client that disconnects halfway through leaves the upstream request to be torn down. An origin that errors while the client is still uploading leaves the inbound request stream to be cleaned up.

Expect: 100-continue adds a decision point before the body. The proxy can wait for the origin to accept or reject the body before it tells the client to send, or it can make the call itself and respond directly. The server lifecycle chapter covered the event names. For proxy code, the part that counts is that an informational response can gate the upload before any body bytes move.

A small cleanup function handles the error case.

js
function onDone(err) {
  if (!err) return;
  req.destroy(err);
  upstream.destroy(err);
  res.destroy(err);
}

This cleanup destroys the live objects whenever either pipeline reports an error. Real proxy code usually splits request-side and response-side errors apart, so it can send a cleaner status while headers are still uncommitted. Either way, an aborted side cannot be allowed to leave the other side waiting forever.

Timeouts also work best when they sit close to the operation that can stall. The client upload has its own timeout on the client-to-proxy request. The connect step has one on the proxy-to-origin request. The response wait has one on the upstream response. Collapse all of those into a single generic timer and every failure reads as the same vague "proxy timed out." Keep each timer next to the work it guards.

Buffering changes the contract. With buffering, the proxy reads some or all of a body into memory or temporary storage before it forwards anything. That buys the proxy the ability to inspect a complete body, retry a request it is still holding before the origin, compute a new Content-Length, or scan content before the origin ever sees it. It also costs latency and memory or disk. A 2 GB upload turns into local storage pressure inside the proxy on top of the socket pressure.

For large bodies, streaming usually wins on latency and memory. The origin starts receiving bytes while the client is still uploading, and the proxy's memory stays bounded by stream buffers rather than by body size. What gets harder is failure timing, because both HTTP exchanges are live at once. The client can still be sending after the origin has already rejected the request. The origin can close while the proxy still holds unread client bytes. The proxy has to land each of those failures on the correct side of the exchange.

Framing the body is its own decision. The inbound request might carry Content-Length. The upstream request can reuse that length only if it forwards the exact same body. The moment the proxy decompresses, rewrites, filters, or buffers and alters the body, the old length is stale. Drop it and let Node frame the new body, or set a new length once the rewrite is settled.

Chunked bodies are a per-hop affair too. The client-to-proxy request may use chunked transfer coding, and the proxy-to-origin request may use it as well, but those chunks are transport framing for one hop only. The origin should get the same message body bytes, not the client's original chunk markers showing up as application data. Node's HTTP layer takes care of that when you stream an IncomingMessage into a ClientRequest. JavaScript sees body chunks as stream data, while the wire chunking stays hop-level framing.

Consuming the response body still counts. A proxy that calls http.request() upstream and then ignores upstreamRes can strand the origin response body and ruin connection reuse. Stream it to the client, drain it on purpose, or destroy it when the response is going to be thrown away. That is the same obligation from the client chapter, except the proxy now plays two roles at once, a server to the public client and a client to the origin.

Trailers call for an explicit choice. An origin can send trailer fields after a chunked response body, and Node exposes them on the message once the body completes. A streaming proxy that wants to forward them has to keep the trailer contract intact on the client response as well. On the way out, response.addTrailers() only emits trailer bytes for chunked responses, and the Trailer header has to name those fields before the body starts.

Plenty of small proxies just drop trailers, since application code rarely leans on them. That is a deliberate behavior choice. Forwarding them instead means taking on extra response-state work.

Static File Serving

To serve a static file, you turn a request target into a file path, read that file's metadata off the filesystem, then stream its bytes into the response.

The dangerous step is the path mapping. A request path is a URL path; a filesystem path is a local path on the process. Decode and constrain the URL path before you let it anywhere near the filesystem.

The core of the check is short.

js
const root = resolve('public');
const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
const file = resolve(root, '.' + pathname);
const insideRoot = file === root || file.startsWith(root + sep);

root is the directory the server exposes. pathname is the path pulled out of the request target. The resolve(root, '.' + pathname) call normalizes . and .. segments relative to the root, and insideRoot confirms the final path is still inside the exposed directory.

A real handler does more on top of this. It catches bad percent-encoding, turns away directories unless it has an explicit index rule, and treats Windows path edge cases with care.

Symlinks call for a policy of their own. Chapter 4 covered how they behave. A static handler can allow a symlink only when its resolved real path stays under the static root, or it can refuse symlinks outright. The string prefix check above is an early guard for directories you control.

Once the path is known to be safe, fs.stat() reads the metadata.

js
const stat = await fs.stat(file);

if (!stat.isFile()) {
  res.writeHead(404);
  return res.end();
}

stat.isFile() keeps directories, device files, and other special filesystem objects out of a plain static handler. Chapter 4 covered those file types. This handler deals only with regular file content.

The file can change in the window between fs.stat() and createReadStream(). In a lot of static directories that window does not count for much, because deployment writes are atomic and files stay immutable after publication. In directories that change while requests are live, the size and mtime can drift apart from the bytes you stream. A stricter handler opens the file first, stats the open handle, and streams from that handle, so the metadata and the content come from one open file object as far as the platform allows.

A media type is the Content-Type label for the response body, something like text/css, image/png, or application/javascript. Node core hands MIME database selection to userland for static servers. Small handlers often keep a local table for the handful of types they serve, while production handlers reach for a maintained package or a platform static server.

With metadata and a media type in hand, the handler can send headers.

js
const type = mediaType(file) ?? 'application/octet-stream';

res.writeHead(200, {
  'Content-Length': stat.size,
  'Content-Type': type,
});

Content-Length comes straight from stat.size, since the file size is known before streaming starts. Content-Type comes from the file extension, or from a stronger server-owned policy. application/octet-stream is the binary fallback.

Media type detection should stay dull. A file extension table is usually enough for assets you control. Sniffing bytes opens up policy questions and inconsistent client behavior. For backend services, setting the media type from server-owned routing or extension data tends to be cleaner than guessing intent from the content.

Streaming the file keeps memory bounded.

js
pipeline(
  createReadStream(file),
  res,
  err => err && res.destroy(err),
);

fs.createReadStream() reads the file in chunks and writes them through the response stream. Backpressure runs between the response socket and the file stream, so a slow client slows the file reads down instead of forcing the whole file into memory.

Errors after the headers are committed are brutal. If the file stream errors before the response starts, the handler can still send a 404 or 500. If it errors after bytes have gone out, the server can only kill the response. So a static handler checks existence, type, access, and basic metadata before it writes a single header.

HEAD wants the same headers as GET, just no body.

js
if (req.method === 'HEAD') {
  res.writeHead(200, headers);
  return res.end();
}

The server still computes the metadata, it just skips the body stream. That keeps HEAD useful for clients checking size, type, validators, and cache state.

Content-Disposition tells a client how to present the response. inline asks the client to show the content in place where it can. attachment asks it to download the content, often under a given filename.

http
Content-Disposition: attachment; filename="report.csv"

Treat filenames as output-encoded data. Header values follow their own grammar. Raw user input dropped into filename can produce invalid headers or confuse the client. Static handlers usually take filenames from server-owned metadata, or run them through a tested encoding helper.

Static file serving reads as simple because the common path is short. The care goes into the handoffs between layers, mapping a URL path onto a filesystem path, turning filesystem metadata into HTTP metadata, translating stream errors into response state, and tying cache validators to conditional responses. Handle each one on purpose.

Conditional Requests

Send validators with a static file and the responses get dramatically cheaper on repeat requests.

An ETag is a validator string that stands for one representation of a resource. For static files, a common weak validator combines the file size with the modification time. Weak here means the tag is reliable enough to validate a cache, without promising a byte-for-byte match.

A simple weak ETag helper fits in a few lines.

js
function weakEtag(stat) {
  const mtime = Math.trunc(stat.mtimeMs).toString(16);
  return `W/"${stat.size}-${mtime}"`;
}

The helper builds a weak ETag out of data fs.stat() already returned, so it costs almost nothing. It can miss a change that keeps the same size and timestamp precision on some filesystems, so treat it as a practical validator rather than a content hash. A content hash is stronger, and it costs you file reads or precomputed metadata.

The W/ prefix marks the validator as weak. For deciding whether to send a static asset's body again, weak validation usually does the job. For range recombination and byte-for-byte comparison, a stronger validator serves you better. A static server doing aggressive range caching often switches to content hashes or deployment-generated metadata in place of mtime alone.

Last-Modified is just the file modification time written as an HTTP date.

js
const validators = {
  ETag: weakEtag(stat),
  'Last-Modified': stat.mtime.toUTCString(),
};

ETag usually makes a better comparison key than Last-Modified, because timestamps run into precision and clock limits. Static files commonly send both, and a client uses whichever validator it happens to be holding.

Cache-Control carries the cache directives. For a versioned asset like /assets/app.8f3a1c.js, a long max-age makes sense, because the URL itself changes whenever the content does. For something like /app.js, a shorter lifetime or a validation-heavy policy is safer, because that URL can start pointing at new bytes later on.

A long-lived versioned asset might use this.

http
Cache-Control: public, max-age=31536000, immutable

That response says shared caches may store the asset, its fresh lifetime is one year, and clients can treat it as unchanged for that whole year. Save it for content-addressed or versioned assets. Ordinary user files and API-style responses call for a different cache policy.

Cache-Control is end-to-end metadata, so a proxy forwards it by default. A caching proxy may also act on it. Even a pass-through reverse proxy should keep passing the directive along, since downstream clients and caches may rely on it.

A conditional request asks the server to check a validator before sending the body. For a cached static file, the client might send this.

http
If-None-Match: W/"1842-19b0b68c5f2"

If-None-Match can carry * or a list of entity tags, and it uses weak comparison. A production parser should reject malformed entity tags. The local helper below handles the tag format this static handler emits.

js
function weakTagMatch(header, current) {
  const value = String(header ?? '').trim();
  if (value === '*') return true;

  const body = tag => tag.replace(/^W\//, '');
  const tags = value.match(/W\/"[^"]*"|"[^"]*"/g) ?? [];
  return tags.some(tag => body(tag) === body(current));
}

When the current ETag matches any tag the client asked about, the server can answer 304 Not Modified.

js
const noneMatch = req.headers['if-none-match'];

if (weakTagMatch(noneMatch, validators.ETag)) {
  res.writeHead(304, { ...cacheHeaders, ...validators });
  return res.end();
}

A 304 Not Modified carries headers and nothing else. It tells the client to reuse its stored response body while refreshing the cache metadata from these response headers. The server should include the validators and the relevant cache headers it would have sent on a normal 200.

On a 304, the handler never opens the body stream. Compute the validators, evaluate the conditional headers, return 304 when the condition holds, and only build the file stream afterward for a full response. That skips reading file bytes the client already has.

If-Modified-Since uses Last-Modified rather than an ETag. Its comparison is timestamp-based.

js
const since = Date.parse(req.headers['if-modified-since'] ?? '');
const lastModifiedMs = Date.parse(validators['Last-Modified']);

if (noneMatch === undefined && Number.isFinite(since) && lastModifiedMs <= since) {
  res.writeHead(304, { ...cacheHeaders, ...validators });
  return res.end();
}

Last-Modified is an HTTP date, so the comparison runs against the exact value sent in the response header. That stops filesystem sub-second precision from tripping up a client that sends the server's own Last-Modified value back.

When If-None-Match and If-Modified-Since both arrive, the ETag path decides. The timestamp path is there as a fallback for clients that only have Last-Modified.

Conditional handling also sharpens the question of who validates. When Node serves the static assets, Node builds and checks the validator. When a reverse proxy serves them, the proxy does. When Node forwards to an origin, validation usually stays with the origin, and Node forwards the conditional request instead of evaluating it on its own.

Range Requests

Once files get large, clients start asking for ranges.

A range request asks for part of a representation. A byte range points at positions inside the file.

http
Range: bytes=0-1023

That one asks for the first 1024 bytes. An open-ended range runs from a start position to the end of the file, and a suffix range asks for the last N bytes. Video players, download resumers, and various tools lean on these so they can grab a segment without pulling the whole file again.

Parse ranges strictly. bytes=100-199 is a single byte range. bytes=100- means byte 100 through the end. bytes=-500 means the last 500 bytes. bytes=200-100 is invalid, since the end falls before the start. Multiple ranges come comma-separated and need a different response body format.

Normalize the range against the current file size before you open any stream. A suffix range bigger than the file collapses to the whole file. An open-ended range stops at the final byte. A start position sitting exactly at the file size selects nothing, so it belongs on the range-failure path rather than in a stream built from impossible offsets.

Keep the arithmetic in a single helper, and exercise it with zero-length files, one-byte files, and exact final-byte cases. Range bugs are usually off-by-one errors, and the headers around them still look plausible.

A single-range response uses 206 Partial Content.

http
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/7340032
Content-Length: 1024

Content-Range states the selected byte positions together with the full representation length. Content-Length states how many bytes are in this response body, which is the range length, not the whole file length.

The stream uses inclusive start and end offsets.

js
const stream = createReadStream(file, { start, end });
res.writeHead(206, rangeHeaders(start, end, stat.size));
pipeline(stream, res, onDone);

fs.createReadStream() takes inclusive start and end offsets, which lines up with HTTP byte ranges, where bytes=0-1023 covers both endpoints. The response headers carry the range length, end - start + 1.

A range response should reuse the same validators and cache policy the full response would have carried on a 200. A client may stitch partial responses together later, and mismatched validators make that unsafe. For a small handler, build the shared headers once, then layer on Content-Range, the range Content-Length, the 206 status, and the ranged stream.

Compression makes range serving harder. A byte range refers to the selected representation bytes. Compress on the fly and the byte positions in the compressed response stop lining up with the file offsets on disk. Small static handlers usually serve ranges from the stored bytes and skip on-the-fly compression for ranged responses. Larger servers tend to precompress assets and treat each encoded variant as its own representation with its own validators.

For media files, test seeks near the start, in the middle, on the final byte, across suffix cases, and against tiny local files.

Multi-range responses use multipart/byteranges, a body made of several parts that each carry their own range metadata. A small Node static handler can support a single range and answer multi-range requests with a normal 200 instead. That simpler behavior covers a lot of internal tools. A public static server should either support the wider protocol surface or hand static serving to a component that already does.

Accept-Ranges: bytes advertises byte-range support.

http
Accept-Ranges: bytes

The header is advisory. A client can send a range request without seeing it first, and the server still decides whether this particular request gets a partial response. For static files, send it once the handler's range code works.

Conditional requests and range requests can cross paths. If-Range says the client wants a range only while its validator still matches; if the validator has moved on, the server sends the full current representation. A minimal static handler can skip If-Range and just return full responses for the tricky cases. What it should never do is send a 206 with stale or mismatched metadata.

A range that starts past the current file length wants either a range-failure response or a full-response fallback, depending on how much the handler supports. A 206 with an impossible Content-Range is protocol-broken. A 200 with the full body is often fine when the handler decides to ignore an unsupported Range header. A precise range failure helps clients that resume downloads, though that extra status path comes with its own tests.

Proxying Static Files and Platforms

In real deployments, the reverse proxy, the static handler, and the streaming body usually share one server.

One Node process might serve /assets/* off disk, proxy /api/* to an internal service, and stream /upload/* to an origin that stores large objects. They share a single incoming HTTP server, but they have nothing else in common. Serving /assets/* is about local filesystem state. Proxying /api/* is a second HTTP exchange. Streaming /upload/* keeps two body flows live at once.

Keep the branches separate and explicit.

text
/assets/*  -> stat file, send validators, stream file
/api/*     -> filter headers, forward request, stream response
/upload/*  -> stream request body, stream upstream response

Whichever branch runs decides who owns the response. The static branch owns the status, the validators, the media type, and the file stream. The proxy branch usually keeps the origin status and the end-to-end response headers, once it has stripped the hop-by-hop fields. The streaming upload branch wants its cleanup paths ready early, because either side can fail while the other is still busy.

Mixing the branches in one server is fine, as long as the handler picks a branch before it writes the response. Once a proxy response has forwarded origin headers, it can no longer fall back to a local static response. Once a static response has sent a Content-Length, it can no longer turn into a streaming upstream response. That first header commit settles the response owner for the whole exchange.

TLS termination sits a layer above this chapter. A proxy may take HTTPS from the public client and speak plain HTTP to Node. Chapter 11 covers the certificate and TLS behavior. At the HTTP layer, Node may see X-Forwarded-Proto: https or Forwarded: proto=https when the deployment adds it, and that is proxy-provided metadata like any other.

API gateways, WAFs, CDNs, Kubernetes Ingress, service meshes, and cloud load balancers all sit on the same HTTP mechanics. One routes, another authenticates, another caches, another terminates TLS, another rewrites headers, another buffers request bodies. The Node process underneath still has to know its immediate HTTP peer, strip per-hop metadata before forwarding, stream bodies with backpressure, and build file-backed responses from real filesystem state.

Any of these can lift a job off Node. A platform static server takes the file route. A CDN takes the cache route off the origin. An ingress controller takes the reverse proxy route out of application code. What moves in each case is ownership, while the HTTP mechanics stay put. Someone still maps paths, strips hop-by-hop headers, handles forwarded metadata, evaluates validators, and streams bodies.

Once every HTTP exchange has a clear owner, the checklist stays short. The socket peer, the HTTP peer, the upstream request, the body stream, the file metadata, the validator, and the platform handoff are the things to confirm.