What happens to a packet’s size when it hops from one proxy to another? If your instinct is to reach for the familiar packet-networking toolkit — Path MTU Discovery, IP fragmentation, ICMP “Fragmentation Needed” messages — you are about to be surprised. The moment traffic enters a proxy chain, the rules of the game change entirely, and the MTU/MSS mental model that served you so well in routing and switching becomes, at best, misleading and, at worst, a source of needless configuration mistakes.

This deep dive explores how MTU and MSS actually behave in proxy chaining environments, why the packet-based intuition breaks down, and what really happens when one proxy leg has a smaller MSS than the next.

This post addresses terminating proxies that establish a new TCP connection on each leg. NAT devices and pure L4 splicers are out of scope; they preserve the end-to-end connection.

The Familiar World: MTU, MSS, and Fragmentation

Before entering proxy territory, let us anchor on the traditional model, because it is precisely this model that creates the confusion.

Maximum Transmission Unit (MTU) is the largest frame size a given Layer 2 link can carry. On standard Ethernet, this is 1500 bytes. After subtracting the IP header (20 bytes for IPv4) and the TCP header (20 bytes), the Maximum Segment Size (MSS) — the largest payload a single TCP segment can carry — comes out to 1460 bytes. This is the number endpoints negotiate during the three-way handshake via the MSS TCP option.

When a router forwards an IP packet onto a link with a smaller MTU than the packet’s size, one of two things happens:

  1. IP fragmentation (if the Don’t Fragment bit is clear): the router splits the packet into smaller fragments, each with its own IP header, and the receiver reassembles them.
  2. ICMP “Fragmentation Needed” (if DF is set): the router drops the packet and sends an ICMP message back, prompting the sender to reduce its segment size — the engine of Path MTU Discovery (PMTUD), defined in RFC 1191.

The key takeaway: in this world, packet boundaries are preserved across hops. The same blob of data, with its headers, travels from source to destination, merely reframed or fragmented along the way (technically, TTL is decremented at each hop and checksum is recomputed for IPv4 packets). The endpoints are aware of the path’s bottleneck MTU and size their TCP segments accordingly.

This is the mental model most network engineers carry. And it is precisely the model that collapses inside a proxy chain.

The Paradigm Shift: Proxies Terminate, They Do Not Forward

A proxy is not a router. This sounds obvious, but its implications for MTU/MSS are profound and frequently misunderstood.

When a router forwards a packet, it operates at Layer 3. It inspects the destination IP, decrements the TTL, and retransmits the same packet — same payload, same TCP header, same sequence numbers — onto the next interface. The TCP connection is end-to-end, spanning all the routers in between. Those routers never participate in the TCP state machine.

A proxy, by contrast, terminates the TCP connection. It is a participant in the TCP state machine on both sides. Consider a simple two-hop chain (two proxy hops, hence three TCP legs):

  • Leg 1: Client ↔ Proxy A (a full TCP connection, with its own three-way handshake, its own MSS negotiation, its own sequence numbers).
  • Leg 2: Proxy A ↔ Proxy B (a completely separate TCP connection, with its own handshake, own MSS, own sequence numbers).
  • Leg 3: Proxy B ↔ Destination (yet another independent TCP connection).

There is no single TCP connection spanning client to destination. There are three. Each one has its own independently negotiated MSS, its own congestion window, its own retransmission logic. The proxies sit in the middle, participating in two TCP state machines simultaneously.

The proxy does not forward packets. It reads a byte stream from one socket and writes that same byte stream to another socket. The packets are reborn on every leg.

This is the core insight that reshapes everything about MTU and MSS in proxy environments.

What the tcp mss Socket Option Actually Sets

Many modern proxy engines expose a configuration knob — typically named something like tcpMss, tcpMaxSeg, or sockopt tcp mss — that lets you manually set the MSS on the sockets the proxy creates. What does this actually do under the hood?

It calls setsockopt() with the TCP_MAXSEG option on the underlying TCP socket, before the connection is established. The Linux manual page is explicit about the behavior:

The maximum segment size for outgoing TCP packets. If this option is set before connection establishment, it also changes the MSS value announced to the other end in the initial packet. Values greater than the (eventual) interface MTU have no effect. TCP will also impose its minimum and maximum bounds over the value provided.

There are several crucial details buried in that description:

  1. It is a per-socket, per-leg setting. It affects only the TCP connection that the proxy itself is opening on that leg. It has no bearing on the traffic flowing through the proxy from another leg.

  2. It only influences the MSS advertised in the outgoing SYN. The final MSS used on that leg is the minimum of what both ends advertise — standard TCP MSS negotiation. You cannot force the remote end to use a larger MSS than it offers.

  3. The kernel clamps it. If you request an MSS larger than the interface MTU allows, the kernel silently caps the value to the path-MTU-based maximum. If you request something absurdly small, the kernel imposes its own minimum (the exact value is OS‑dependent and is not defined by RFC 9293).

So when you set tcp mss on a proxy’s outbound configuration, you are tuning the segment size of that proxy’s own outgoing TCP connection on that specific leg. Nothing more.

Proxy Chaining: Independent Legs, No Reconciliation

Now we arrive at the heart of the confusion. In a multi-hop proxy chain, how are the different MSS values on different legs reconciled?

They are not. And this is where the packet-networking intuition fails.

In the router world, if leg 2 has a lower MTU than leg 1, the router must fragment (if allowed by its configuration) to get the oversized packets through. The bottleneck MTU is a path property, discovered end-to-end, and the endpoints adjust.

In the proxy world, each leg is an independent TCP connection with its own MSS. The proxy reads bytes from leg 1 and writes them to leg 2. The OS on leg 2 independently segments that byte stream according to leg 2’s own negotiated MSS, congestion window, and the receiver’s advertised window. There is no “reconciliation” because there is no shared end-to-end path property to reconcile.

Consider this scenario:

  • Leg 1 (Client → Proxy A): Negotiated MSS = 1460 (standard Ethernet).
  • Leg 2 (Proxy A → Proxy B): Manually configured MSS = 1200 (perhaps over a constrained tunnel).
  • Leg 3 (Proxy B → Destination): Negotiated MSS = 1460.

What happens to a 1448-byte application write from the client?

  • On Leg 1, it likely travels as a single TCP segment (1448 bytes fits within MSS 1460).
  • Proxy A reads those 1448 bytes from its incoming socket. It now has 1448 bytes in its buffer, with no memory that they arrived as one segment.
  • On Leg 2, the OS segments those 1448 bytes according to MSS 1200: it sends one segment of 1200 bytes and one of 248 bytes. Two TCP segments, two sequence number ranges, two potential retransmission units.
  • Proxy B reads those bytes (across however many segments arrive) and reconstructs the 1448-byte stream in its buffer.
  • On Leg 3, the OS sends those 1448 bytes as a single segment again (MSS 1460).

The destination receives 1448 bytes. It has no idea that they were ever split into two segments on an intermediate leg. The bytes are preserved; the segmentation is not.

The Abstraction That Changes Everything: What is a Byte Stream?

To truly understand why the packet-forwarding mental model fails in a proxy chain, we have to strip away the network cables and routers for a moment and look at what TCP actually presents to the application.

If your background is in routing and switching, you are conditioned to think of data as discrete, bounded units: an IP packet, an Ethernet frame. You can point to a header, measure the payload, and draw a hard boundary around the data.

TCP does not work this way. TCP provides a byte stream.

A stream has no inherent structure, no boundaries, and no concept of “packets” as far as the application is concerned. It is a continuous, ordered sequence of bytes flowing from a sender to a receiver.

The API Illusion

This abstraction is baked into the most fundamental network programming APIs. When an application wants to send data, it doesn’t call a send_packet() function. It calls send() (or write()).

When an application calls send(socket, "HELLO WORLD", 11, 0), it is not handing a packet to the operating system. It is simply appending 11 bytes to a local kernel buffer. The application can call send() five times with 2 bytes each, or one time with 10 bytes—TCP makes no promises to the receiver about how those writes were grouped.

On the other side, the receiver calls recv() (or read()). If the sender wrote “HE”, “LL”, and “O”, the receiver might call recv() and get “H”, then “ELLO”, then “LLO” in subsequent calls. Or it might get “HELLO” all at once. The boundaries of the sender’s send() calls are completely erased by TCP.

Segmentation is an Implementation Detail

Because the application only deals with a stream, the concept of a “TCP segment” is merely an invisible implementation detail of the transport layer.

When the application puts 4,000 bytes into the kernel buffer, the kernel doesn’t necessarily send one 4,000-byte packet (which would violate MTU anyway). Nor does it necessarily send exactly how the application wrote it. Instead, the kernel acts as an assembler on one end and a disaggregator on the other:

  • The Sender Kernel: Looks at the stream of bytes in the buffer, checks the negotiated MSS, the current congestion window, and Nagle’s algorithm, and slices off a chunk of bytes to put into an IP packet.
  • The Receiver Kernel: Takes the chunk of bytes out of the arriving IP packet and drops them into a receive buffer, reassembling the continuous stream in order.

You can actually prove that TCP tracks bytes, not packets, by looking at the TCP header. The Sequence Number field doesn’t count packets (Segment 1, Segment 2, Segment 3). It counts bytes (Byte 1, Byte 1461, Byte 2921).

Why This Matters for Proxies

When a proxy sits in the middle, it is just two applications back-to-back.

  1. It calls recv() on Leg 1 and gets a chunk of bytes from the kernel buffer.
  2. It takes those exact same bytes and calls send() on Leg 2, putting them into the kernel buffer for the next leg.

The proxy code never sees a TCP segment. It never sees IP headers. It just sees an array of bytes in memory. Because the proxy only interacts with the stream abstraction, any concept of the “original packet size” from Leg 1 is destroyed the moment the kernel hands those bytes to the proxy application.

With this understanding of the byte stream firmly in place, we can look at how proxies actually handle data across multiple legs.

The Continuous Stream Model: Chunking, Not Fragmentation

This brings us to the mental shift we need. Is the proxy working in a discrete manner, or on a continuous stream?

The stream is continuous; the segmentation is discrete and per-leg. The proxy’s internal model is a byte stream — in Go, this is the io.Reader/io.Writer interface; in C, it is send()/recv(). The proxy calls Read() on the incoming socket and gets back some number of bytes (any number, depending on what has arrived and the kernel’s buffer state). It then calls Write() on the outgoing socket with those same bytes. The kernel on the outgoing side decides how to chunk them into TCP segments.

There are no “packets” inside the proxy. There are bytes. The segmentation — the translation from bytes to packets — happens anew on every leg, according to that leg’s own TCP parameters.

This is why a lower MSS on the next proxy does not trigger fragmentation in the IP sense. There is nothing to fragment, because there is no packet to begin with — only a stream. The lower MSS simply means that leg will emit smaller TCP segments. The receiving proxy reads them as a stream and emits them onward however its own outgoing socket prefers.

Concern Packet Forwarding (Router) Proxy Chaining
Operates at Layer 3 (IP) Layer 4 / Layer 7
Preserves packet boundaries? Yes No
TCP connection scope End-to-end Terminated and re-created per leg
MTU mismatch response IP fragmentation or ICMP PMTUD Re-segmentation of stream (invisible to app)
MSS reconciliation Path-wide via PMTUD None — each leg is independent
Bottleneck awareness Endpoints learn via ICMP No end-to-end signaling

Should You Adjust MSS on Intermediate Proxies?

Given all this, the practical question is: should you manually configure tcp mss on the intermediate proxies in your chain, or leave it at the default?

In nearly all cases, leave it unchanged. Here is the reasoning:

  1. You cannot improve throughput by lowering MSS. A smaller MSS means more segments, more overhead (more TCP headers per byte of payload), and potentially more retransmissions. It never makes a healthy path faster.

  2. You cannot “pre-fragment” to avoid downstream problems. Because each leg re-segments independently, shrinking MSS on leg 2 does not prevent leg 3 from sending large segments. You would only be hurting leg 2’s efficiency for no end-to-end benefit.

  3. The kernel already handles path constraints. If the physical path on a given leg has a constrained MTU (say, PPPoE at 1492, or a VPN tunnel at 1400), the kernel’s own PMTUD and MTU discovery will negotiate an appropriate MSS for that leg automatically. Manual intervention is redundant.

  4. There is one narrow exception. If a leg traverses a path where ICMP is blocked (breaking PMTUD) and the path has a known, fixed MTU constraint, manually clamping the MSS on that leg’s socket can prevent black-hole PMTUD failures. This is the same reason iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu exists. But this is a workaround for broken PMTUD, not a performance optimization — and it should be applied only on the affected leg, not blanket-set across all proxies.

The bottom line: unless you are diagnosing a specific PMTUD black hole on a specific leg, or deliberately tuning for traffic-shaping evasion, leave the MSS unset and let the kernel negotiate it.

A Lesson in Layer Discipline

The MTU/MSS confusion in proxy environments is, at its root, a failure of layer discipline. The packet-forwarding mental model is so deeply ingrained in network engineers that we instinctively apply it everywhere. But proxies live at a different layer. They terminate connections, not forward packets. They operate on streams, not frames. And the moment you cross a TCP termination boundary, the segmentation of one leg has no bearing on the segmentation of the next.

The next time you find yourself reaching for tcp mss on a proxy configuration, ask: which leg’s TCP connection am I actually configuring, and what problem am I trying to solve? If the answer is “I want to avoid fragmentation on the next hop,” you are almost certainly solving the wrong problem — because in a proxy chain, there is no next-hop fragmentation to avoid. There is only a stream, re-chunked anew on every leg.

Further Reading