TCP/IP Model
Place a failure at the layer that owns it — refused, dropped or slow — and price the TCP handshake in round trips, ephemeral ports and connection reuse.
You do not need exhaustive protocol detail for a system design interview, but you do need to know which layer a problem lives at — because that determines which component can fix it. A connection that is refused, one that hangs, and one that returns a 504 look identical to a user and have three different owners.
The four layers, and what each one can see
Each layer adds one header and reads only its own. That is the whole model, and it is why a component can only fix a problem at the layer it parses.
Application is where your protocol lives — HTTP, gRPC, SMTP, DNS. It is the only layer that sees a URL, a method, a header or a body, so it is the only layer that can route on a path, retry a request, or judge a response an error.
Transport moves bytes between two processes, addressed by port. TCP opens with the three-way handshake (SYN, SYN-ACK, ACK), numbers every byte, retransmits whatever goes unacknowledged, delivers in order, and lets the receiver advertise a window so a fast sender cannot drown a slow one. UDP does none of it: no handshake, no ordering, no retransmission, an 8-byte header against TCP's 20.
Internet carries IP addresses and routing, hop by hop, best effort. It promises to try, not to deliver — packets are dropped when a queue fills and arrive out of order after taking different paths. Routers work here and read the IP header only. Everything TCP guarantees is built on a layer that guarantees nothing.
Link is one hop across one medium — Ethernet, Wi-Fi — addressed by MAC, and switches work here. A 1,500-byte Ethernet MTU minus 20 bytes of IP and 20 of TCP leaves a 1,460-byte segment, so a 15 KB JSON response is eleven segments on the wire and any one of them going missing stalls the rest until it is retransmitted.
The transport choice is where the layering earns its keep, and the usual mistake is to make it on the word "faster". UDP does not push bytes down the wire any quicker; it saves one round trip of setup and it never waits for a straggler. That is worth having only when a late byte has no value. A voice call from India to US East runs a ~200 ms round trip, so a lost 20 ms audio frame cannot be retransmitted and used — the replacement lands at least 200 ms after it was due, and the jitter buffer has already played silence over the gap. Dropping it is correct. DNS takes the same deal for a different reason: query and answer fit in one datagram, so re-asking is cheaper than recovering. A missing byte of a JSON body is never correct, and reaching for UDP there ends with retransmission, ordering and congestion control rebuilt in application code — TCP again, with far fewer people testing it. Networks and the cost of a round trip works the same trade-off through the call.
Which layer owns the failure
Three symptoms, three owners. The whole diagnosis turns on what the client got back.
| What the client sees | Layer that owns it | What happened |
|---|---|---|
Connection refused, immediately | Transport | The host answered the SYN with an RST. It is up; nothing is listening on that port |
| The connection hangs, then times out | Internet or link | The SYN was dropped in silence — a firewall rule, a security group, a route that does not exist. Nothing on the far side saw it |
| The connection opens, then a 504 | Application | The kernel completed the handshake and the process is slow or wedged. The network did its job |
A refusal is a deliberate answer and arrives in one round trip. A drop has no answer at all, so the client waits out its own timeout — and if the caller set none, it waits out TCP's. Linux retries a connect on a schedule of roughly 1, 3, 7, 15, 31, 63 seconds, six attempts by default: about 130 seconds before the call fails. A thread parked for two minutes against a peer that quietly went away is a connection pool draining itself one request at a time. TCP's reliability is not a substitute for an application timeout; it is why the default is so long.
The handshake is not free, and it runs out of ports
The three-way handshake costs one round trip before a byte of the request moves. Inside a datacenter that is 0.5 ms and invisible. From a phone in India to an origin in US East it is roughly 200 ms, before TLS adds another — see the latency ladder.
The cost that surprises people is not time, it is addresses. A connection is identified by a four-tuple of source IP, source port, destination IP and destination port. One proxy talking to one backend IP on one port varies only the source port, and Linux hands those out of an ephemeral range defaulting to 32768–60999 — 28,232 of them. Whichever side closes first holds the socket in TIME_WAIT for 60 seconds, so a straggling duplicate cannot land inside a new connection reusing the same four-tuple. That fixes a ceiling:
28,232 ports ÷ 60 s TIME_WAIT ≈ 470 new connections/second
A proxy comfortably serving 2,000 QPS therefore falls over the moment something disables keep-alive and leaves the proxy closing each connection, because one connection per request needs four times the ports that exist. It drains the range in about 28,232 ÷ 2,000 ≈ 14 seconds, then fails to connect with CPU idle and bandwidth free. The symptom reads as a network outage; the fix is an application-layer decision to reuse connections.
Raising the ceiling is the alternative worth rejecting out loud: widen the port range, enable tcp_tw_reuse, or give the backend more IPs to multiply the four-tuple space. Each buys a few times more headroom and none removes the work. Keep-alive deletes the handshake rather than paying for it faster, which is why a layer 7 balancer multiplexes thousands of client connections onto a handful of pooled connections per backend — see load balancing.
A health check that passes while everything times out
The handshake completes in the kernel, not in your process. listen() gives the socket a backlog queue; the kernel finishes SYN, SYN-ACK, ACK on its own and parks the established connection there until the application calls accept(). An application whose every worker is blocked on a slow database still has a kernel completing handshakes on its behalf.
A layer 4 health check proves only that a TCP connection opens, so it passes on a process that has not served a request in minutes. The balancer keeps the instance in rotation and keeps feeding it traffic it cannot absorb. A layer 7 check that requires a 200 from a handler touching the same dependencies as the request path fails when the request path fails — which is the point of monitoring the thing users actually do.
In an interview
Nobody asks you to recite four layers. The question arrives as a symptom — "users say the site is down, the servers look healthy" — and what is graded is whether you narrow it before proposing anything.
Say what you would ask for: "What does the client actually see? Refused means the host is up and nothing is listening, so it is a process or port problem. A hang means the packet is being dropped in silence, so I am looking at firewall rules and routes. A 504 after the connection opened means the network delivered and the application is the problem. Those are three different owners." Then place your components: TCP or UDP is a transport-layer choice driven by whether a late byte still has value, HTTP inherits whatever that transport gives it, and a balancer's capability is fixed by the layer it works at.
The mistake that loses points is using a layer as a label instead of a boundary — "that's a layer 4 issue", with no account of what layer 4 can and cannot see. The version that scores states the constraint: a layer 4 balancer picks a backend once when the connection opens and never parses a byte of payload, so it cannot route on a URL or retry a failed request; a layer 7 balancer can, and spends a few hundred microseconds against a 0.5 ms in-datacenter round trip to do it. The runner-up is answering a latency problem at the wrong layer, adding bandwidth to a path whose cost is round trips.
Check yourself
1. A proxy holds keep-alive to a single backend and serves 2,000 QPS. Upstream pooling is switched off: the proxy now opens a connection per request and closes it itself as soon as the response is complete. What breaks, and how soon?
The proxy sends the first FIN, so every socket holds its four-tuple in TIME_WAIT on the proxy for 60 s. Only the source port varies against one backend IP and port, so the ceiling is 28,232 ÷ 60 ≈ 470 new connections/second. At 2,000 QPS the range drains in about 28,232 ÷ 2,000 ≈ 14 seconds, after which connects fail while CPU and bandwidth sit idle. Which side closes decides where the damage lands: send
Connection: closeon the request instead and HTTP/1.1 obliges the backend to tear down, so the proxy's port is freed through CLOSE_WAIT and LAST_ACK in milliseconds and it is the backend that accumulates 2,000 × 60 = 120,000 TIME_WAIT sockets, pressuring its socket and conntrack tables rather than exhausting anyone's ports. Restore keep-alive; widening the port range or adding backend IPs only raises a ceiling you should not be near.
2. Your p99 target is 200 ms. A request crosses a layer 7 balancer, then makes 5 sequential internal calls, each opening a fresh TCP connection because pooling is off. What is the network floor, and where does the budget go?
Each fresh call is one handshake round trip plus one request round trip: 2 × 0.5 ms = 1 ms, five of them = 5 ms, plus the balancer hop. That is 2.5% of 200 ms, so connection setup is not the constraint. The budget belongs to the services: 200 ms ÷ 5 = 40 ms per call at p99, and any dependency whose own p99 is 40 ms or worse breaks the target alone. Pooling recovers 2.5 ms; it does not rescue a design with five sequential calls in it. Fan them out and the floor becomes one call, not five.
3. The balancer reports every instance healthy and users get timeouts. The check opens a TCP connection to port 8080 and closes it. First hypothesis, and what change tests it?
The kernel completes the handshake and queues the connection in the listen backlog whether or not the application ever calls accept(), so a layer 4 check proves the machine is up, not that the process is serving. First hypothesis: every worker is blocked on a downstream dependency while the backlog keeps the instance looking healthy. Test it by moving the check to a layer 7 GET whose handler touches that dependency, with a timeout well under the balancer's interval — instances then fail out honestly instead of absorbing traffic they cannot serve.