OSI and TCP/IP models
A layer model answers one question about a protocol or a box: which headers can it read, and so what can it decide? OSI is ISO’s seven-layer reference model (ISO/IEC 7498-1); the Internet runs the TCP/IP suite, which RFC 1122 (1989) describes in four layers. The OSI protocols lost, but the numbering survived as vocabulary, so engineers say “L4” for TCP and “L7” for HTTP on a stack that has no layer 5 or 6.
Treat OSI as a teaching tool. Real systems cross layers constantly: TLS sits between TCP and HTTP, QUIC fuses transport and encryption, NAT rewrites L4 ports inside an L3 box, and an L7 load balancer terminates TCP. RFC 3439 (2002) titled a section “Layering Considered Harmful”. For one request traced through every layer, see What happens when you enter a URL.
Layers, jobs and units
Each layer wraps what the layer above hands it in a header of its own, and the wrapped unit - the protocol data unit (PDU) - has a name interviewers use:
| OSI layer | Job | TCP/IP layer and PDU |
|---|---|---|
| 7 Application | the program’s protocol: HTTP, DNS, SMTP | Application: message |
| 6 Presentation | encoding, compression, encryption | Application |
| 5 Session | dialogue state, resumption | Application |
| 4 Transport | process to process, by port | Transport: segment (TCP), datagram (UDP) |
| 3 Network | host to host, across networks | Internet: packet |
| 2 Data link | one hop on one link, by MAC address | Link: frame |
| 1 Physical | bits as voltage, light or radio | Link (RFC 1122 has no L1) |
Textbooks often teach a five-layer hybrid that splits Physical out of Link. Layers 2, 3, 4 and 7 mean the same in every version, and they are the only numbers anyone uses in conversation.
Three mechanisms behind the table
- Each header names the next parser. EtherType in the Ethernet header
says what the payload is (
0x0800IPv4,0x86DDIPv6,0x0806ARP), the IP protocol field does the same one layer up (6 TCP, 17 UDP, 1 ICMP), and the port picks the socket. Receiving is that chain of lookups. - Addresses have a scope. A MAC address is valid on one link and every router rewrites it; an IP address is end to end and changes only at a NAT; a port means something only inside one host. A packet that crosses ten routers travels in ten different frames.
- The layers run in different places. L1-L2 in the NIC and its driver,
L3-L4 in the kernel, everything above in your process - TLS included,
since Python’s
sslmodule is OpenSSL running in-process. QUIC moves the transport itself into user space.
Encapsulation, in bytes
Going down the stack each layer prepends its header; going up each strips its own and passes the rest along:
HTTP GET /users HTTP/1.1 ... message
│ TLS: +5 header, +1 type byte, +16 tag
TLS [5][ ciphertext of message + 1 ][16] record
│ TCP: +20 to 60, slices the byte stream
TCP [20-60][ bytes of one or more records ] segment
│ IP: +20 (IPv4) or +40 (IPv6)
IP [20][ segment ] packet
│ Ethernet: +14 header, +4 checksum
Eth [14][ packet, at most the MTU ][4] frameRecords and segments do not line up. A TLS record holds up to 16 KB, so it spans about a dozen segments, and one segment can carry the tail of one record and the head of the next: TCP moves bytes, not messages.
Header sizes
| Header | Bytes | Note |
|---|---|---|
| Preamble + SFD | 8 | on the wire only; no capture shows it |
| Ethernet II | 14 | +4 per 802.1Q VLAN tag |
| Ethernet FCS | 4 | CRC-32 trailer; bad frames are dropped silently |
| Inter-frame gap | 12 | idle line time between frames |
| IPv4 | 20-60 | 20 in practice; IP options are rare |
| IPv6 | 40 | fixed; extension headers extra |
| TCP | 20-60 | 32 with timestamps, which Linux sends by default |
| UDP | 8 | fixed |
| TLS 1.3 record | 22 | 5 header + 1 type + 16 AEAD tag, per record |
| WireGuard | 60 / 80 | outer IPv4 / IPv6 + UDP 8 + its own 32 |
MTU, MSS and what is left
MTU (maximum transmission unit) is the largest IP packet a link carries: 1500 bytes on Ethernet, counting the IP header but not Ethernet’s 14 + 4. MSS (maximum segment size) is the largest TCP payload a host accepts, announced once, in its SYN. RFC 6691 sets the arithmetic: announce the MTU minus the fixed IP and TCP headers, then subtract each segment’s options.
14 20 20 12 1448 4
┌─────┬──────┬──────┬────┬───────────────┬─────┐
│ Eth │ IP │ TCP │ TS │ TCP payload │ FCS │
└─────┴──────┴──────┴────┴───────────────┴─────┘
│◀─────────── MTU 1500 ───────────▶│
│◀──── MSS 1460 ────▶│So IPv4 announces 1460 and, once both sides use the 12-byte timestamp option (TS), carries 1448 per segment; IPv6 announces 1440 and carries 1428. On the wire each frame also costs 38 bytes of preamble, header, FCS and gap, so efficiency is payload / (MTU + 38):
| Stack | Payload | Efficiency |
|---|---|---|
| IPv4, no options | 1460 | 94.9% |
| IPv4 + timestamps | 1448 | 94.1% |
| IPv6 + timestamps | 1428 | 92.8% |
| Inside WireGuard | 1368 | 88.9% |
| AWS jumbo, MTU 9001 | 8949 | 99.0% |
The WireGuard row is IPv4 with timestamps inside an 80-byte IPv6 outer header. 1448 / 1538 = 94.1% is the ~941 Mbit/s iperf3 shows on a clean gigabit link, not a slow NIC.
TLS barely registers: 22 bytes per 16 KB record is 0.13%. The costs that matter are per packet, which is why jumbo frames help, tunnels hurt, and QUIC, which seals every packet with its own 16-byte tag, pays per packet what TLS over TCP pays per record.
What your Python process sees
The kernel strips L2 to L4 and OpenSSL, inside the interpreter, strips TLS. What survives of the lower layers is metadata you have to ask for:
import socket
import ssl
HOST = "example.com"
raw = socket.create_connection((HOST, 443))
print("L3/L4", raw.getpeername()) # peer IP and port: all that is left of L2-L4
print("MSS ", raw.getsockopt(socket.IPPROTO_TCP, socket.TCP_MAXSEG))
ctx = ssl.create_default_context()
ctx.set_alpn_protocols(["http/1.1"])
tls = ctx.wrap_socket(raw, server_hostname=HOST) # SNI + ALPN go in the ClientHello
print("TLS ", tls.version(), tls.cipher()[0], tls.selected_alpn_protocol())
tls.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
print("L7 ", tls.recv(17)) # plaintext: OpenSSL, in this process, removed TLSL3/L4 ('172.66.147.243', 443)
MSS 1400
TLS TLSv1.3 TLS_AES_256_GCM_SHA384 http/1.1
L7 b'HTTP/1.1 200 OK\r\n'This side announced 1460 (1500-byte Ethernet), and a connection uses the smaller offer, so 1400 was the server’s. A value under 1460 means the server reserves room for extra headers - a tunnel, an encapsulating load balancer - instead of betting on path MTU discovery. Never hard-code 1460.
When large responses hang: path MTU discovery
TCP sends full-size segments with the IP DF (don’t fragment) bit set. When a link on the path has a smaller MTU - a VPN, a tunnel, an overlay - the router in front of it drops the packet and returns ICMP “fragmentation needed” (type 3 code 4; ICMPv6 type 2, Packet Too Big) carrying the smaller MTU. The sender caches it and resends in smaller segments. That is path MTU discovery (RFC 1191; RFC 8201 for IPv6, whose routers never fragment).
The black hole
It works only if the ICMP message gets back. Drop it - a NACL with no ICMP rule, a firewall told to “block ping” - and every full-size packet vanishes silently while the sender retransmits the same oversized segment. The signature: the TCP handshake and small responses work, large responses stall after the first bytes. Two commands prove it from the client side:
ping -M do -s 1472 api.example.com # DF set; 1472 + 8 ICMP + 20 IP = 1500
ping -M do -s 1372 api.example.com # 1400 on the wire
tracepath -n api.example.com # reports the pmtu per hopIf the 1500-byte probe vanishes without an error while the 1400-byte one is
answered, something drops by size and says nothing; tracepath may even be
fooled into reporting 1500. On macOS the probe is ping -D -s 1472; on
Windows, ping -f -l 1472.
Fixes, in the order to try them
- Clamp the MSS where the tunnel starts, so every forwarded SYN asks for
segments that fit:
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu. TCP only. - Let the ICMP through: IPv4 type 3 code 4 and ICMPv6 type 2. Never deny all ICMPv6; IPv6 runs neighbour discovery over it.
- Give the tunnel the right MTU. WireGuard’s default 1420 is 1500 minus 80 (IPv6 40, UDP 8, WireGuard 32); AWS Site-to-Site VPN carries at most MTU 1446 / MSS 1406.
- Let hosts probe.
net.ipv4.tcp_mtu_probing=1(RFC 4821) shrinks segments after a black-hole timeout without any ICMP. Off by default.
Two cloud twists. Inside one AWS VPC instances use 9001-byte jumbo frames, while traffic through an internet gateway or a VPN is capped at 1500 - so two jumbo hosts across a VPN hit exactly this (VPN). And an ECMP router or L4 balancer hashes a flow’s 5-tuple to pick a server, but the ICMP error has no ports of its own and can land on a server that never saw the connection; Cloudflare’s fix was to broadcast it to every server.
Protocols that do not fit
| Protocol | Filed under | Why it does not fit |
|---|---|---|
| TLS | L6, or “application” | on TCP, under HTTP; runs inside your process |
| QUIC | L4 | a transport inside UDP, with TLS 1.3 built in |
| ARP | L2 | Ethernet payload (0x0806) mapping IPv4 to MAC |
| NDP | L3 | IPv6’s ARP: ICMPv6 messages doing a link-layer job |
| ICMP | L3 | carried in IP (protocol 1), yet “an integral part of IP” |
| BGP | L7 | a TCP application (port 179) that steers L3 routing |
| OSPF | L3 | a routing protocol directly on IP (protocol 89) |
| DHCP | L7 | UDP 67/68 traffic that hands out the L3 address |
| IPsec | L3 | ESP is IP protocol 50; its IKE key exchange is UDP 500/4500 |
| VXLAN, WireGuard | tunnel | whole frames or packets inside UDP: the stack recurses |
OSI files encryption under L6, so TLS gets called “L6”; TCP/IP has no slot
and calls it application. It talks to both neighbours: SNI names the host
and ALPN picks the L7 protocol (h2, http/1.1) during the handshake, before
any HTTP byte exists (TLS, HTTPS, Certificates).
Is QUIC layer 4 or layer 7?
Both answers are defensible, which is the point of the question. By function QUIC is a transport: streams, ordering, loss recovery, congestion control (RFC 9000). By position it is application code, a user-space library sending UDP datagrams. And it absorbs TLS 1.3: the handshake rides in QUIC’s own CRYPTO frames, with no TLS record layer (RFC 9001).
The keys then hide what TCP keeps in its header - ACKs, windows, stream offsets travel encrypted, and the packet number is masked. HTTP/3 is HTTP over QUIC (HTTP versions: 1.1, 2 and 3). What an on-path box loses:
| TCP + TLS | QUIC | |
|---|---|---|
| Seq, ACK, flags, window | readable | encrypted |
| Connection start and end | SYN, FIN, RST | only handshake packets look different |
| Forge RST, clamp MSS | possible | impossible: headers are authenticated |
| Flow identity | 5-tuple | connection ID, survives an address change |
| Still visible | ports, TCP header, handshake | UDP ports, connection IDs, a few bits |
With nobody on the path able to clamp it, QUIC sizes its own packets: it needs a path that carries 1200-byte UDP payloads and probes for more. A client moving from Wi-Fi to cellular keeps its connection ID but changes its 5-tuple, breaking L4 balancers that hash addresses; the QUIC-LB draft, not an RFC as of 2026-10, puts a server ID in the connection ID for that reason. Where UDP 443 is blocked, browsers fall back to HTTP/2 over TCP.
Where the boxes sit
| Box | Layer | Reads, decides, rewrites |
|---|---|---|
| Switch | L2 | MAC addresses; an “L3 switch” also routes |
| Router | L3 | destination IP; new L2 header per hop, TTL minus 1 |
| NAT gateway | L3 + L4 | rewrites IPs and ports, fixes checksums, keeps state |
| Security group | L3-L4 | 5-tuple plus connection state; drops silently |
| L4 balancer | L4 | picks a backend per connection |
| L7 balancer | L7 | ends TCP and TLS; picks a backend per request |
| WAF, API gateway | L7 | method, path, headers, body |
| VPN gateway | L3 tunnel | wraps whole packets in an encrypted outer packet |
Two rows break the picture. NAT is filed under L3, but the common kind rewrites ports, and even address-only NAT must fix the TCP checksum, which covers the IP addresses through a pseudo-header (NAT (Network Address Translation)). An L7 balancer is two TCP endpoints glued by an HTTP parser: two connections, two MSS negotiations, two sets of timeouts (HTTPS Handshake, mTLS, Keepalive, and the Reverse-Proxy Edge).
L4 vs L7, the question behind the question
“Is it L4 or L7?” asks what the box can see, and so what it can do:
| L4 balancer | L7 balancer | |
|---|---|---|
| Balances | connections | requests, or HTTP/2 streams |
| Reads | 5-tuple; the TLS SNI at most | method, path, host, headers, cookies |
| TCP | forwarded, or relayed byte for byte | terminated; a new connection to the backend |
| TLS | passed through, or ended with no HTTP | terminated, so it can read HTTP |
| Client IP | kept, or sent by PROXY protocol | gone; rebuilt from X-Forwarded-For |
| gRPC | all RPCs on a connection hit one backend | RPCs spread across backends |
| Examples | AWS NLB, IPVS, kube-proxy, HAProxy mode tcp |
AWS ALB, nginx, Envoy, HAProxy mode http |
The gRPC row is the trap: every call shares one long-lived HTTP/2 connection, an L4 balancer places that connection once, and one backend takes all of the client’s calls, even after you scale out. Use an HTTP/2-aware L7 balancer or client-side balancing (gRPC's HTTP/2 Basis). L4 suits databases, raw TCP and TLS passthrough.
An L4 proxy can still peek one layer up. The hostname crosses in cleartext in
the ClientHello, so nginx ssl_preread, HAProxy req.ssl_sni and Envoy’s
TLS inspector route by SNI while relaying encrypted bytes. Encrypted Client
Hello (RFC 9849) hides the real name from boxes without the ECH keys.
What the backend loses behind an L7 balancer
Ending TCP and TLS costs the backend the client IP (its TCP peer is the
balancer) and the scheme (the request arrives as plain HTTP). An L7 hop
restores them as X-Forwarded-For and X-Forwarded-Proto, or Forwarded
(RFC 7239).
An L4 hop cannot add headers to a stream it does not parse, so it prepends a
PROXY protocol line before the client’s first byte:
PROXY TCP4 203.0.113.7 10.0.1.20 51234 443\r\n (AWS NLB sends version 2,
the same data in binary).
Those headers are text the client can also write, and each trusted proxy
appends the address it saw: read X-Forwarded-For from the right, skipping
exactly your trusted hops. Behind one ALB, 6.6.6.6, 203.0.113.7 means the
client is 203.0.113.7, and 6.6.6.6 is whatever the client sent. The count
holds only if nothing reaches the backend except through those hops.
Uvicorn trusts the headers only from --forwarded-allow-ips (default
127.0.0.1 and ::1); * on a backend reachable another way hands the
client IP to the client. More in
Proxies: Forward vs Reverse.
Debugging by layer
| Layer | First tool | What it catches |
|---|---|---|
| L1-L2 | ip -s link, ethtool -S eth0 |
link down, CRC errors, drops |
| L2 | ip neigh |
FAILED neighbour: wrong subnet or dead gateway |
| L3 | ip route get <ip>, mtr, tracepath |
no route, loss at a hop, path MTU |
| Cloud L3-L4 | VPC flow logs | REJECT: a security group or NACL dropped it |
| L4 | ss -tanp, nc -vz host 443 |
refused or timed out; who owns the port |
| L4 | tcpdump 'tcp[tcpflags] & tcp-rst != 0' |
which side sent the reset |
| L4 state | dmesg |
nf_conntrack: table full, dropping packet |
| TLS | openssl s_client -connect h:443 -servername h |
chain, SNI, ALPN, version |
| L7 | curl -v, curl -w timings |
status, headers, which phase is slow |
Bisect rather than walk the stack: if nc -vz connects, L1 to L4 work and
the fault is TLS or above. Refused means a RST came back, so the host is
reachable and nothing listens (or a firewall rejects); timed out means
silence, so something dropped the SYN. Full toolbox:
Network Troubleshooting Tools.
Related
Interview angle 4
- “A load balancer terminates TLS and routes by path. Which layer is it,
and what does the backend lose?” - L7: it can read the path only after
ending TCP and TLS. The backend’s TCP peer is now the balancer and the
request arrives as plain HTTP, so it loses the client IP and the scheme.
- Follow-up: “How does it get them back, and when must it refuse them?” -
X-Forwarded-ForandX-Forwarded-Proto(orForwarded) from an L7 hop, PROXY protocol from an L4 one. Trust them only when the TCP peer is your own proxy, read from the right skipping exactly your trusted hops, and make the backend unreachable except through them.
- Follow-up: “How does it get them back, and when must it refuse them?” -
- “Small API calls work over the VPN, but large responses hang. Which
layer?” - L3, path MTU. The tunnel lowers the MTU, full-size segments
carry DF, and a firewall drops the ICMP “fragmentation needed” (type 3 code
4; ICMPv6 type 2) that should say so, so the server resends the same
oversized segment until the connection dies.
- Follow-up: “Which two commands prove it, and what fixes it?” -
ping -M do -s 1472vanishes while-s 1372answers, andtracepathshows the pmtu drop, or is fooled into 1500 when ICMP is filtered. Clamp the MSS at the tunnel, then let type 3 code 4 through.
- Follow-up: “Which two commands prove it, and what fixes it?” -
- “Is QUIC layer 4 or layer 7?” - a transport by function, application
code by position (a user-space library over UDP), with TLS 1.3 inside.
“Both, and here is why” is the answer they want.
- Follow-up: “What can an L4 middlebox no longer see or do?” - sequence numbers, ACKs, flags, where connections start and end; it cannot forge resets or clamp the MSS. It sees UDP ports and connection IDs, and a migrating client changes its 5-tuple under any balancer hashing on it.
- “L4 or L7 load balancer for a gRPC service?” - L7 or client-side.
Every call shares one long-lived HTTP/2 connection and L4 places
connections, so one backend gets all of a client’s calls.
- Follow-up: “When is L4 the right answer?” - non-HTTP protocols, TLS passthrough when only the backend may hold the keys, and very high connection rates; it keeps the client IP without parsing anything.