Reverse Proxies, Static Files, and Streaming Bodies
A reverse proxy sits between the public client and the server that will actually handle the request.
The client sends one HTTP request to the proxy. The proxy then sends a separate HTTP request to the origin server. The origin server is the HTTP server responsible for the selected resource after routing has chosen the host, path, and method. In a Node deployment, that origin server is often your application process, even when the public client never connects to that process directly.
A reverse proxy receives client requests, applies routing and forwarding rules, creates or reuses an upstream connection, sends a new request toward the origin server, and copies the response back to the client while keeping each HTTP connection's rules separate.
Chapter 9 covered the TCP part. socket.remoteAddress tells your backend about the immediate TCP peer. Behind a reverse proxy, that peer is the proxy. HTTP then adds headers, request targets, forwarding metadata, and body streaming on top of that transport fact.
The usual reverse-proxy path looks like this -
client
-> reverse proxy
-> origin serverThe deployment chooses the reverse proxy. A public DNS record, load balancer listener, platform router, CDN, ingress controller, or local front process sends client traffic to the proxy first. The client targets the public service endpoint. Your backend sees the proxy as the connected peer.
A forward proxy is chosen from the client side. Client code, a browser, an agent, or a network policy sends outbound requests to the proxy because that client environment says outbound traffic should pass through it.
The usual forward-proxy path looks like this -
client
-> forward proxy
-> origin serverA reverse proxy fronts servers. A forward proxy controls outbound client traffic. Both are HTTP intermediaries, and both can change what each endpoint sees. The code becomes confusing when it forgets who chose the proxy and which side the proxy is protecting.
Proxy Roles
An origin server is the server that finally handles the selected HTTP resource. It may be a Node http.Server, a framework running on top of it, a static file server, an object storage endpoint, or another HTTP service. The useful idea is ownership - the origin server is where the routed request becomes real application work.
Reverse proxy routing usually belongs to deployment. The proxy might choose an origin from the public Host header, the URL path prefix, a configured upstream name, or a local rule that sends /api to one process and /assets to another. Unless your Node process is the proxy, Node only sees the request that the proxy decided to send upstream.
Even a tiny reverse proxy owns a serious handoff. It receives bytes from one socket, parses one HTTP request, then creates another HTTP request on another socket. That upstream request can use another Host header, another keep-alive connection, another timeout policy, and another body framing decision. The response comes back through the same proxy, then the proxy writes a response to the original client.
A reverse proxy usually receives origin-form requests from clients -
GET /api/users/42 HTTP/1.1
Host: example.comThe client sends a path in the request line and names the public host in the Host header. The reverse proxy already has a routing table that says where traffic for example.com should go. It builds a new upstream request from the incoming request and from that routing table.
A forward proxy usually receives absolute-form request targets for ordinary HTTP requests -
GET http://example.com/api/users/42 HTTP/1.1
Host: example.comThe request target includes the scheme and authority because the proxy needs to know the final destination. The forward proxy reads that destination from the request, applies local policy, opens or reuses a connection to the origin server, and forwards the request.
Those request-target forms came up in the HTTP wire-format chapter. Here, the useful rule is simple. A reverse proxy handler usually sees req.url starting with /. A forward proxy handler can see req.url containing http://.... If one router tries to treat both the same way, it can send traffic to the wrong place.
The Host header needs the same care. A reverse proxy may preserve the original public host because the origin routes by public host. Or it may rewrite Host to match the upstream service because the origin expects an internal name. Both are common. The correct choice comes from the agreement between proxy and origin.
A Node origin that expects Host: api.internal should receive that. A Node origin that builds public links from the original host needs forwarded host metadata or the original Host value preserved.
Path rewriting is another proxy rule. A proxy can receive /api/users/42 and forward /users/42. It can also preserve the whole path. The origin only sees the upstream request, so route debugging has to check both values - what the public client sent and what the proxy forwarded.
Reverse proxies also tend to end one HTTP connection and start another. The client-to-proxy connection can use keep-alive while the proxy-to-origin connection is newly opened. The reverse can also happen. Parser state, connection reuse, timeouts, and per-connection headers belong to each side separately.
At that point, the proxy has real HTTP work to do. It has to preserve the request method, target meaning, and body bytes. It has to decide which headers can move onward and which headers belong only to the current connection. It has to stream request and response bodies without breaking backpressure. It has to decide whether forwarded client metadata is added, overwritten, or ignored. If the same server also serves static files, it has to choose between upstream forwarding and file-backed response construction before it commits the response.
The proxy also translates failures. A refused upstream connection might become 502 Bad Gateway. A slow upstream response might become 504 Gateway Timeout. A rejected client request might become 400, 413, or another local response before the origin sees anything.
Underneath those status choices, the Node mechanics are smaller - connect upstream, write 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
Forward proxies have one method path that deserves separate handling - CONNECT.
CONNECT asks the proxy to open a TCP connection to the authority named in the request target. The target uses authority-form -
CONNECT api.example.com:443 HTTP/1.1
Host: api.example.com:443The proxy reads api.example.com:443, opens a TCP connection to that host and port, and replies with a successful response when the connection is ready. After that response, the client and target exchange raw bytes through the proxy.
That byte path is called a proxy tunnel. The HTTP parser handles the setup request. Once the tunnel is accepted, the bytes that follow are raw stream data from the proxy's point of view.
TLS often appears inside this path because HTTPS clients use CONNECT through forward proxies. The client asks the proxy for a tunnel to port 443, then the TLS handshake runs inside that tunnel between the client and the destination. Chapter 11 covers TLS mechanics. For this chapter, the key idea is that the proxy sees the CONNECT request and the target authority, then forwards opaque bytes after the tunnel is established.
The proxy still applies policy before the tunnel opens. It can allow only specific ports, block private address ranges, require proxy authentication, or reject malformed authorities. Those decisions happen while the request is still HTTP.
After the tunnel is accepted, the proxy has a byte stream between two sockets. HTTP-level inspection ends for that tunneled connection unless the proxy terminates the encrypted protocol itself. That termination path belongs with TLS and platform design.
Node exposes incoming CONNECT requests on http.Server with the "connect" event -
server.on('connect', (req, clientSocket, head) => {
console.log(req.url);
clientSocket.end();
});req.url contains the authority-form target, such as api.example.com:443. clientSocket is the connection from the client to the proxy. head contains any bytes that arrived after Node finished parsing the CONNECT request. Real tunnel code connects to the target and forwards both sockets. This tiny version only shows where Node gives the tunnel request to JavaScript.
head is easy to lose by accident. It belongs to the tunnel byte stream. If the proxy connects to the target, it has to write head to the target socket before piping the rest of the client socket. Otherwise, the first bytes after the CONNECT request can disappear.
A client using core http.request() also has a "connect" event when a proxy replies to a CONNECT request -
const req = http.request(proxyOptions);
req.on('connect', (res, socket, head) => {
console.log(res.statusCode);
});That client-side event means the proxy answered the CONNECT request. A 200 response usually means the tunnel is available. From that point on, code owns the socket bytes directly. The normal HTTP response body path is done.
For ordinary HTTP through a forward proxy, the proxy still parses HTTP messages. For HTTPS through CONNECT, the proxy parses only the tunnel setup request and response. The encrypted protocol inside the tunnel belongs to the client and the destination.
A reverse proxy can still receive CONNECT if a client sends it, but most reverse proxies reject it or route it through explicit tunnel handling. Treat CONNECT as its own method path. Passing it into an ordinary app router usually produces useless behavior because there is no normal request body to parse after tunnel setup. Node gives you a separate event because the socket lifecycle changes after the parser recognizes the method.
Node Built-In Proxy Support
Node v24 has built-in proxy support for outbound clients. The broad feature landed in v24.5.0 and is marked active development in the v24 docs. The runtime helper http.setGlobalProxyFromEnv() landed in v24.14.0. Treat this as current v24 behavior and check the docs when upgrading.
This feature helps a Node process act as a client behind a forward proxy. It can route http.request(), https.request(), and fetch() through proxies configured from environment variables or an explicit object. Reverse proxy behavior is still server-side code - accept an inbound request, create an upstream request, and stream the response back.
At process start, NODE_USE_ENV_PROXY=1 or --use-env-proxy tells Node to create the global HTTP agent with proxy environment settings. At runtime, http.setGlobalProxyFromEnv() updates the global HTTP agent, HTTPS agent, and Undici global dispatcher.
Here is a runtime setup using explicit proxy environment values -
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 passed here is proxyEnv. It uses the same field names as the proxy-related environment variables. The returned restore function puts the global agent and dispatcher settings back to their earlier values.
Call this during bootstrap. Calling it while requests are already running can change outbound routing while other code is still using the old global agent or dispatcher.
That global reset reaches wider than core http. It also affects the default outbound route for fetch() because Node fetch uses Undici underneath. A library that only calls fetch() can start using the proxy after the process calls http.setGlobalProxyFromEnv(). That may be exactly what an enterprise runtime wants. It can also surprise tests that expected direct loopback calls.
HTTP_PROXY supplies a proxy URL for HTTP requests. HTTPS_PROXY supplies a proxy URL for HTTPS requests. Lowercase names, http_proxy and https_proxy, have precedence when both cases are present. Proxy URLs can include http://, https://, and userinfo for proxy authentication. Proxy authentication policy becomes product-specific quickly, so this chapter leaves it at the routing level.
NO_PROXY is the bypass list. It names destinations that should use a direct client connection instead of the configured proxy. Node v24 supports exact hostnames, domain suffixes such as .internal.example.com, wildcard domain patterns such as *.internal.example.com, exact IP addresses, IP ranges, host-and-port entries, and * for bypassing all proxy routing. Entries are comma-separated. Lowercase no_proxy wins over uppercase NO_PROXY when both are set.
NO_PROXY is evaluated against the request destination. The proxy URL is separate. That is important when a process calls both public services and internal services. HTTPS_PROXY=https://proxy.internal:8443 can be active while NO_PROXY=.svc.internal,localhost sends internal service calls directly. A failing internal request then belongs to the direct DNS and TCP path. A failing public request may belong to the proxy connection or the proxy-to-origin connection.
A custom http.Agent can receive proxyEnv directly -
const agent = new http.Agent({
proxyEnv: process.env,
});That keeps proxy routing scoped to requests using that agent, instead of resetting global agent and dispatcher state. Use the global API when the whole process should follow one outbound proxy policy. Use an explicit agent when only one client path needs that policy.
Unix domain socket requests ignore these proxy settings. A Unix socket target is already a local socket path, so HTTP proxy URL routing has no remote host to choose.
For debugging, log the final request destination and whether an explicit agent or global proxy setup is involved. A request that unexpectedly reaches a proxy often comes from process-level setup, a lowercase environment variable overriding uppercase, or a NO_PROXY entry that does not match the host and port actually used by the client.
Hop-by-Hop Headers
Blind header copying breaks proxies.
Some headers describe only one HTTP connection. Others describe the HTTP message as it moves toward the final recipient. A reverse proxy has at least two HTTP connections - client to proxy, and proxy to origin. Those two connections need their own connection-specific metadata.
Connection is the control header. Its value can name other header fields that apply only to the current connection.
Here is an example -
Connection: keep-alive, x-local-state
X-Local-State: parser-7The proxy has to remove both connection and x-local-state before forwarding the request. X-Local-State became hop-by-hop because Connection named it. A fixed removal list catches the common fields, but the tokens listed inside Connection are the part people miss.
Common hop-by-hop fields include Connection, Keep-Alive, TE, Trailer, Transfer-Encoding, and Upgrade. Proxy-specific authentication fields, Proxy-Authenticate and Proxy-Authorization, belong to the proxy hop too. Proxy-Connection is non-standard, but old clients and intermediaries still send it. Practical proxy code should remove it because forwarding it only adds confusion to the next hop.
A basic set looks like this -
const hopByHop = new Set([
'connection', 'keep-alive', 'proxy-authenticate',
'proxy-authorization', 'proxy-connection',
'te', 'trailer', 'transfer-encoding', 'upgrade',
]);Node lowercases names in message.headers, so the set uses lowercase names. A proxy that works from rawHeaders needs case-insensitive handling because HTTP header names are case-insensitive.
The runtime part comes from Connection -
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 removes fields named by Connection, then removes the common fixed set. It returns 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 need more careful handling. headersDistinct and rawHeaders give you the raw material when you need that level of detail.
TE has one special detail. TE: trailers is valid only for the current connection when the sender also lists TE in Connection. A proxy that forwards TE without that connection option changes the protocol contract for the next hop. Filtering it with the rest of the hop-by-hop set is the safer default for a small proxy.
End-to-end headers move onward unless the proxy has a reason to rewrite them. Content-Type, Cache-Control, ETag, Last-Modified, application headers, and most request metadata are meant for the next HTTP recipient and often the final recipient. The default decision comes from the header's job - connection-specific data is consumed at the current hop, while exchange metadata continues.
Transfer-Encoding is where mistakes become serious. The client-to-proxy request can be chunked. The proxy-to-origin request can also be chunked, or it can have a Content-Length, or it can carry no body. The proxy should choose upstream framing from the body it will actually send.
Copying both Content-Length and Transfer-Encoding from a client request is broken. Copying either one after changing the body is broken too. If the proxy streams the original body unchanged, Node can generate upstream chunked framing when the length is absent. If the proxy buffers and rewrites the body, it should set the new length or let Node frame the new stream.
Upgrade is another connection-specific path. A request with Upgrade: websocket asks the current HTTP peer to switch protocols on the current connection after a response. A reverse proxy can support that, but it has to handle the upgrade event and socket handoff explicitly. Ordinary request forwarding code cannot copy Upgrade to the origin and still treat the response as normal HTTP. After an upgrade, the connection is no longer an ordinary HTTP message stream.
Response headers follow the same rule. The origin's Connection header describes the origin-to-proxy connection. The proxy's response to the client needs its own connection metadata, usually generated by Node from the server response state. Forward the response status and end-to-end headers. Remove hop-by-hop fields.
A small test catches the common bug. Send a request with Connection: x-test-hop and X-Test-Hop: remove-me, then assert the origin receives no x-test-hop. Send an origin response with the same pair and assert the client receives no copy. That test catches the bug that a plain removal list misses.
The upstream request needs its own headers after filtering -
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. method and path preserve the client request behavior for a simple reverse proxy. hostname and port select the origin endpoint. headers is a fresh header object for the upstream hop. If the proxy rewrites the path or host, this is where the rewrite is applied.
The Host header is often the deliberate exception to direct copying. http.request() can generate a host header from hostname and port, or the proxy can pass an explicit Host value. Choose one. Sending a public Host to an origin that routes by internal name can miss the route. Sending an internal Host to an origin that builds public redirects can produce bad URLs. The proxy owns that contract.
Request timeouts belong on the upstream request too. A proxy can accept a client request, connect to the origin, wait for response headers, wait for response body bytes, or wait for writes to drain. Each wait belongs to a specific side of the request. Keep upstream timers separate from inbound client timers so logs and status codes point to the stalled operation.
Request bodies need an early decision. GET and HEAD commonly arrive without a body in backend APIs, but Node still exposes req as a stream because HTTP permits bodies on many methods. A proxy should decide from method, headers, and local policy whether it will forward the body, discard it, or reject the request before body bytes move upstream.
Discarding a body still needs cleanup. If the proxy sends a local error response and leaves the incoming body unread, the client socket may still have bytes waiting behind the response. If the proxy plans to keep the connection alive, unread request bytes can break the next parse. Many small proxies destroy the client connection after rejecting a body-bearing request. More careful code can drain up to a limit, then close or reuse according to message.complete and parser state.
The proxy also needs a response plan before the first upstream byte arrives. It can pass through the origin status, map certain upstream failures to gateway statuses, or intercept specific responses. Once the client response headers are committed, that exchange is locked into the chosen response. That is why proxy code usually wires error handlers before starting both pipelines, sets all headers before body writes, and keeps cleanup close to the stream that can fail.
Forwarded Client Metadata
Forwarded address headers are deployment metadata.
X-Forwarded-For is the common non-standard field for carrying the original client address through proxies. It usually contains a comma-separated chain -
X-Forwarded-For: 203.0.113.10, 10.0.0.5The leftmost value is often the original client address. Later values are often proxy hops. That convention only helps when you know which proxy wrote the header, which proxies appended to it, and which component cleared untrusted incoming values at the edge.
Forwarded is the standardized header. It can carry parameters such as for, by, host, and proto -
Forwarded: for=203.0.113.10;proto=https;host=example.comIt can also contain multiple elements when several proxies add metadata. The field has stricter grammar than the X-Forwarded-* family, including quoting rules for IPv6 addresses and identifiers. Many deployments still use X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto because frameworks and platform routers widely support them.
Treat both styles as request input controlled by deployment policy. A random public client can send X-Forwarded-For unless the edge proxy clears it. A backend that reads the first value without knowing which proxy wrote it turns a client-controlled string into address data.
Trust-proxy policy belongs to the security chapters. The local rule here is smaller. The backend's socket.remoteAddress is the immediate TCP peer. Forwarded headers are HTTP metadata supplied by the request path.
The safest local rule is ownership. The edge proxy owns the first trusted value it writes. Each later trusted proxy owns the value it appends. The backend owns the decision about which proxy peers it accepts as trusted writers. Everything else is string parsing.
A reverse proxy that owns the edge can write fresh metadata -
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 visible to the proxy. If another trusted proxy already added a value, the chain preserves it. An edge proxy that receives traffic directly from the public internet may clear any incoming value first, then write its own value. That choice belongs to the deployment's trust policy, not Node's HTTP parser.
Original protocol and host metadata work the same way. A proxy that receives HTTPS at the edge and sends plain HTTP to a Node origin may add X-Forwarded-Proto: https. A proxy that routes several public hosts to one backend may pass the original host. The backend can use those fields for URL generation, redirects, logging, or routing only after the deployment says which proxy is allowed to set them.
Forwarded metadata also affects logs. A request log that records only req.socket.remoteAddress will show proxy addresses in a proxied deployment. A request log that records only X-Forwarded-For can store client-supplied strings when the edge has loose header handling.
Good logs keep all three pieces during debugging - the immediate peer from the socket, the forwarded chain from HTTP, and the configured trust decision from deployment.
Streaming Proxy Bodies
A streaming proxy starts forwarding before it has received the whole body.
The inbound request body is an http.IncomingMessage, which is a Readable stream. The outbound upstream request is an http.ClientRequest, which is a Writable stream. The origin response is another IncomingMessage. The client response is a ServerResponse, which is a Writable stream. A proxy connects those stream pairs.
A minimal streaming path looks like this -
const upstream = http.request(target, upstreamRes => {
res.writeHead(upstreamRes.statusCode, stripHopByHop(upstreamRes.headers));
pipeline(upstreamRes, res, onDone);
});
pipeline(req, upstream, onDone);The first pipeline() forwards the origin response body to the client. The second forwards the client request body to the origin. Headers are handled before each body pipeline starts. The proxy filters response headers before sending them to the client, and it should filter request headers before constructing target.
Header timing is strict. Once res.writeHead() runs or the first body chunk is written, the client response headers are committed. A later upstream error can close the response, but it cannot turn that response into a clean 502 after bytes have already gone out. A buffering proxy can wait longer before committing response headers. A streaming proxy commits as soon as it has decided to pass through the upstream response.
Backpressure is the reason to use streams here. When the origin connection cannot accept more request bytes, upstream.write() starts returning false. pipeline() responds by pausing reads from req. Node then stops pulling body chunks through the HTTP request stream as aggressively. Eventually TCP flow control tells the client side to slow down. The same process happens in reverse when the client receives the origin response slowly.
Several buffers are involved at once. JavaScript streams have high-water marks. OutgoingMessage has writable state. The HTTP parser has current body state. libuv watches socket readiness. The kernel has send and receive buffers. TCP has flow-control state. A streaming proxy works because pressure can move through those layers instead of collecting the whole body in JavaScript memory.
Here is the request-body flow in plain terms. The origin socket write buffer fills. ClientRequest records pending bytes. The writable stream crosses its high-water mark. pipeline() stops reading from the inbound IncomingMessage. The inbound HTTP parser gives JavaScript fewer body chunks. The client socket's receive side drains more slowly. The kernel advertises a smaller receive window to the client. The client upload slows because the proxy-to-origin side is the slower side.
The response path has the same behavior with another owner. The public client reads slowly. ServerResponse backs up. pipeline(upstreamRes, res) pauses the upstream response stream. Node reads less aggressively from the origin socket. The origin's send buffer may fill. The origin server then sees backpressure on its own response writes. A proxy that preserves this sequence can move very large bodies with stable memory.
Headers are parsed before body streaming starts, so a proxy can reject a request before forwarding the body when policy only needs headers. Once the proxy starts streaming the body upstream, the origin may receive bytes before the proxy has seen the end of the client upload. If the client disconnects halfway through, the upstream request needs teardown. If the origin returns an error while the client is still uploading, the inbound request stream needs cleanup too.
Expect: 100-continue adds a pre-body decision point. The proxy can wait for the origin to accept or reject the body before telling the client to send it. Or the proxy can make its own decision and respond directly. The server lifecycle chapter covered the event names. For proxy code, the useful rule is that an informational response can gate the upload before body bytes move.
A small cleanup function looks like this -
function onDone(err) {
if (!err) return;
req.destroy(err);
upstream.destroy(err);
res.destroy(err);
}This cleanup destroys the live objects when either pipeline reports an error. Actual proxy code often separates request-side and response-side errors so it can send a cleaner status before headers are committed. The rule is still simple - an aborted side must not leave the other side waiting forever.
Timeouts should also stay close to the operation that can stall. A client upload timeout belongs to the client-to-proxy request. An upstream connect timeout belongs to the proxy-to-origin request. An origin response timeout belongs to the upstream response wait. If all of those become one generic timer, every failure looks like "proxy timed out." Keep each timer near the work it protects.
Proxy buffering changes the contract. Proxy buffering means the proxy reads some or all of a body into memory or temporary storage before forwarding it. Buffering lets the proxy inspect a complete body, retry a request still held before the origin, compute a new Content-Length, or scan content before the origin sees it. Buffering also adds latency and memory or disk pressure. A 2 GB upload becomes local storage pressure inside the proxy as well as socket pressure.
Streaming usually gives better latency and memory use for large bodies. The origin starts receiving bytes while the client is still uploading. The proxy's memory stays limited to stream buffers instead of body size. Failure timing becomes more complicated because both HTTP exchanges are active at the same time. A client can still be sending while the origin has already rejected the request. An origin can close while the proxy has unread client bytes. The proxy has to put those failures on the correct side of the exchange.
Body framing follows the same rule as headers. The inbound request might have Content-Length. The upstream request can reuse that length only when the proxy forwards the exact same body. If the proxy decompresses, rewrites, filters, or buffers and changes the body, the old length is stale. Remove it and let Node frame the new body, or set the new length after the rewrite is known.
Chunked bodies are also per-hop. The client-to-proxy request may use chunked transfer coding. The proxy-to-origin request may use chunked transfer coding too, but those chunks are transport framing for that hop. The origin should receive the same message body bytes, not the client's original chunk markers as application data. Node's HTTP layer handles that when you stream the IncomingMessage into a ClientRequest. JavaScript receives body chunks as stream data, while original wire chunking remains hop-level framing.
Response body consumption still counts. A proxy that calls http.request() upstream and then ignores upstreamRes can strand an origin response body and hurt connection reuse. Either stream it to the client, drain it deliberately, or destroy it when the response will be discarded. The same rule from the client chapter applies, but the proxy now has two roles - it is a server to the public client and a client to the origin.
Trailers need an explicit choice. An origin can send trailer fields after a chunked response body. Node exposes them on the message after the body completes. A streaming proxy that wants to forward trailers has to preserve the trailer contract on the client response too. On the outgoing side, response.addTrailers() emits trailer bytes only for chunked responses, and the Trailer header has to name those fields before the body starts.
Many small proxies drop trailers because application code rarely depends on them. Dropping them is a behavior choice. Forwarding them means extra response-state work.
Static File Serving
Static file serving maps an HTTP request target to a file, builds response metadata from filesystem state, and streams file bytes into the HTTP response.
The risky part is the path mapping. Request paths are URL paths. Filesystem paths are local process paths. Decode and constrain the path before touching the filesystem.
Here is the basic idea -
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 comes from the request target path. resolve(root, '.' + pathname) normalizes . and .. segments against the root. insideRoot checks that the final path still belongs to the exposed directory.
A real handler also catches bad percent-encoding, rejects directories unless it has an explicit index rule, and handles Windows path edge cases carefully.
Symlinks need a policy too. Chapter 4 covered symlink behavior. A static handler can allow symlinks only when the resolved real path stays under the static root, or it can reject symlinks altogether. The string prefix check above is an early guard for controlled directories.
After the path is safe, fs.stat() gives metadata -
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. Here, the HTTP rule is direct - this handler serves regular file content.
The file can change between fs.stat() and createReadStream(). For many static directories, deployment writes are atomic and files are immutable after publication, so that race is acceptable. For directories that change under active requests, size and mtime can drift between the headers and the bytes streamed. A stricter handler opens the file first, stats the open handle, and streams from that handle so metadata and content come from the same open file object as much as the platform permits.
A media type is the Content-Type classification for the response body, such as text/css, image/png, or application/javascript. Node core leaves MIME database selection to userland for static servers. Small handlers often use a local table for the few types they serve. Production handlers usually use a maintained package or platform static server.
Once the handler has metadata and media type, it can send headers -
const type = mediaType(file) ?? 'application/octet-stream';
res.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': type,
});Content-Length comes from stat.size because the file size is known before streaming starts. Content-Type comes from the file extension or a stronger server-owned policy. application/octet-stream is a binary fallback.
Media type detection should stay boring. A file extension table is often enough for controlled assets. Sniffing bytes creates policy questions and client inconsistencies. For backend services, setting the media type from server-owned routing or extension data is usually cleaner than trying to infer intent from content.
Streaming the file keeps memory bounded -
pipeline(
createReadStream(file),
res,
err => err && res.destroy(err),
);fs.createReadStream() reads the file in chunks and writes through the response stream. Backpressure works between the response socket and the file stream. A slow client slows file reads instead of forcing the whole file into memory.
Errors after headers are committed are harsh. If the file stream errors before the response starts, the handler can still send 404 or 500. If the stream errors after bytes have gone out, the server can only terminate the response. That is why static handlers should check existence, type, access, and basic metadata before writing headers.
HEAD needs the same headers as GET and no response body -
if (req.method === 'HEAD') {
res.writeHead(200, headers);
return res.end();
}The server still computes metadata. It 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 present the content in place when it can. attachment asks the client to download it, often with a filename -
Content-Disposition: attachment; filename="report.csv"Treat filenames as output-encoded data. Header values have their own grammar. Raw user input in filename can create invalid headers or confusing client behavior. Static handlers usually choose filenames from server-owned metadata or encode them with a tested helper.
Static file serving can look simple because the common path is short. The careful work sits in the handoffs - URL path to filesystem path, filesystem metadata to HTTP metadata, stream errors to response state, and cache validators to conditional responses. If those handoffs stay explicit, the handler stays predictable.
Conditional Requests
Static file responses become much more efficient once the server sends validators.
An ETag is a validator string for a representation. For static files, a common weak validator combines file size and modification time. Weak means the tag is good enough for cache validation, while avoiding a byte-for-byte guarantee.
Here is a simple weak ETag helper -
function weakEtag(stat) {
const mtime = Math.trunc(stat.mtimeMs).toString(16);
return `W/"${stat.size}-${mtime}"`;
}The function builds a weak ETag from data already returned by fs.stat(). That is cheap. It can miss changes that preserve size and timestamp precision on some filesystems, so treat it as a practical validator rather than a content hash. A content hash is stronger, but it costs file reads or precomputed metadata.
The W/ prefix marks the validator as weak. For cache validation of a static asset, that often works well because the server is deciding whether to send the body again. For range recombination and byte-for-byte comparison, stronger validators are better. A static server that supports aggressive range caching often uses content hashes or deployment-generated metadata instead of mtime alone.
Last-Modified is the file modification time formatted as an HTTP date -
const validators = {
ETag: weakEtag(stat),
'Last-Modified': stat.mtime.toUTCString(),
};ETag usually gives a better comparison key than Last-Modified because timestamps have precision and clock limits. Sending both is common for static files. Clients can use whichever validator they already have.
Cache-Control carries cache directives. For a versioned asset such as /assets/app.8f3a1c.js, a long max-age can make sense because the URL changes when content changes. For /app.js, a shorter lifetime or validation-heavy policy is safer because the URL can point at new bytes later.
A long-lived versioned asset might use this -
Cache-Control: public, max-age=31536000, immutableThat response says shared caches may store it, the fresh lifetime is one year, and clients can treat it as unchanged during that lifetime. Use that only for content-addressed or versioned assets. Ordinary user files and API-like responses need another cache policy.
Cache-Control is end-to-end metadata, so a proxy generally forwards it. A caching proxy may also interpret it. A pass-through reverse proxy should still pass the directive through because downstream clients and caches may use 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 -
If-None-Match: W/"1842-19b0b68c5f2"If-None-Match can carry * or a list of entity tags. It also uses weak comparison. A production parser should reject malformed entity tags. This local helper covers the tag format emitted by the static handler -
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 requested tag, the server can send 304 Not Modified -
const noneMatch = req.headers['if-none-match'];
if (weakTagMatch(noneMatch, validators.ETag)) {
res.writeHead(304, { ...cacheHeaders, ...validators });
return res.end();
}304 Not Modified carries headers only. It tells the client to use its stored response body while updating cache metadata from the response headers. The server should include the validators and relevant cache headers it would have sent with a normal 200 response.
On a 304, the handler does not need to open the body stream. Compute validators, evaluate conditional headers, return 304 when the condition matches, and only then create the file stream for a full response. That avoids reading file bytes the client already has.
If-Modified-Since uses Last-Modified instead of an ETag. Its comparison is timestamp-based -
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 uses the same value sent in the response header. That keeps filesystem sub-second precision from defeating a client that sends the server's own Last-Modified value back.
When both If-None-Match and If-Modified-Since are present, the ETag path makes the decision. The timestamp path is a fallback for clients that only have Last-Modified.
Conditional handling also clarifies proxy behavior. If Node serves static assets itself, Node creates and checks the validator. If a reverse proxy serves them, the proxy creates and checks the validator. If Node forwards to an origin, the origin usually owns validation, and Node should forward the conditional request rather than evaluating it locally.
Range Requests
Large static files often use range requests.
A range request asks for part of the representation. A byte range names positions in the file -
Range: bytes=0-1023That asks for the first 1024 bytes. Open-ended ranges ask from a start position to the end. Suffix ranges ask for the last N bytes. Video players, download resumers, and tooling use these requests because they can fetch a segment without transferring 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 because the end comes before the start. Multiple ranges are comma-separated and require another response body format.
Normalize the range against the current file size before opening the stream. A suffix range larger than the file becomes the whole file. An open-ended range stops at the final byte. A start position equal to the file size selects no bytes, so it should go down the range-failure path instead of creating a stream with impossible offsets.
Keep the arithmetic in one helper and test it with zero-length files, one-byte files, and exact final-byte cases. Range bugs are usually off-by-one bugs with headers that look plausible.
A single-range response uses 206 Partial Content -
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/7340032
Content-Length: 1024Content-Range states the selected byte positions and the full representation length. Content-Length states the number of bytes in this response body, which is the range length instead of the whole file length.
The stream uses inclusive start and end offsets -
const stream = createReadStream(file, { start, end });
res.writeHead(206, rangeHeaders(start, end, stat.size));
pipeline(stream, res, onDone);fs.createReadStream() accepts inclusive start and end offsets. That matches HTTP byte ranges, where bytes=0-1023 includes both endpoints. The response headers need the range length - end - start + 1.
Range responses should keep the same validators and cache policy as the full response when those headers would have been sent with 200. The client may combine partial responses later, and mismatched validators make that unsafe. For a small handler, build the shared headers once, then add Content-Range, range Content-Length, status 206, and the ranged stream.
Compression complicates range serving. A byte range applies to the selected representation bytes. If a handler compresses on the fly, the byte positions for the compressed response no longer match 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 often precompress assets and treat each encoded variant as its own representation with its own validators.
For media files, test seeks near the start, middle, final byte, suffix cases, and tiny local files.
Multi-range responses use multipart/byteranges, where the body contains several parts with their own range metadata. A small Node static handler can choose to support one range and ignore multi-range requests by sending a normal 200 response. That simpler behavior works for many internal tools. A public static server should support the larger protocol surface or delegate static serving to a component that already does.
Accept-Ranges: bytes advertises byte-range support -
Accept-Ranges: bytesThe header is advisory. Clients can still send a range request without seeing it first, and the server can still choose whether the current request gets a partial response. For static files, send it once the handler has working range code.
Conditional requests and range requests can interact. If-Range says the client wants a range only if its validator still matches. If the validator has changed, the server sends the full current representation. A minimal static handler can skip If-Range support and return full responses for complex cases. The handler should avoid sending 206 with stale or mismatched metadata.
A range that starts beyond the current file length needs a range-failure response or a full-response fallback according to the handler's chosen support level. Sending 206 with an impossible Content-Range is protocol-broken. Sending 200 with the full body is often acceptable when the handler chooses to ignore an unsupported Range header. Sending a precise range failure is better for clients that resume downloads, but that extra status path needs tests.
Proxying Static Files and Platforms
Reverse proxies, static handlers, and streaming bodies often live in the same deployed path.
A Node process might serve /assets/* from disk, proxy /api/* to another internal service, and stream /upload/* to an origin that stores large objects. Those routes share one incoming HTTP server, but each route has its own owner. Static file routes belong to local filesystem state. Proxied routes belong to another HTTP exchange. Streaming upload routes belong to two active body flows at the same time.
Keep the branches explicit -
/assets/* -> stat file, send validators, stream file
/api/* -> filter headers, forward request, stream response
/upload/* -> stream request body, stream upstream responseThe selected branch decides who owns the response. A static branch owns status, validators, media type, and file stream. A proxy branch usually preserves the origin status and end-to-end response headers after removing hop-by-hop fields. A streaming upload branch needs early cleanup paths because either side can fail while the other side is still active.
Mixing those branches inside one server is fine as long as the handler commits to one branch before writing the response. A proxy response that has already forwarded origin headers cannot become a local static response. A static response that has already sent a Content-Length cannot become a streaming upstream response. The first header commit chooses the response owner for that exchange.
TLS termination sits one layer above this chapter. A proxy may receive HTTPS from the public client and send HTTP to Node. Chapter 11 covers certificate and TLS behavior. At the HTTP layer, Node may see X-Forwarded-Proto: https or Forwarded: proto=https if the deployment adds it. Treat that as proxy-provided metadata.
API gateways, WAFs, CDN behavior, Kubernetes Ingress, service meshes, and cloud load balancers all build on the same HTTP mechanics. Some route. Some authenticate. Some cache. Some terminate TLS. Some add headers. Some buffer request bodies. The Node process still has to know the immediate HTTP peer, remove per-hop metadata when it forwards, stream bodies with backpressure, and build file-backed responses from real filesystem state.
A platform static server can take the file route away from Node. A CDN can take the cache route away from the origin. An ingress controller can take the reverse proxy route away from application code. Those moves change ownership, while the HTTP mechanics remain. Someone still maps paths, removes hop-by-hop headers, handles forwarded metadata, evaluates validators, and streams bodies.
Once each HTTP exchange has a named owner, debugging gets smaller - socket peer, HTTP peer, upstream request, body stream, file metadata, validator, or platform handoff.