System Design (High-Level Design) Module 4 – Networking for system designers
TCP, QUIC, HTTP/1.1 to HTTP/3 and TLS 1.3
How TCP, UDP and QUIC differ, why HTTP/1.1, HTTP/2 and HTTP/3 stall on packet loss in different ways, what TLS 1.3 saves, and when 0-RTT is safe.
What you will learn
- Compare TCP and UDP for request-response and streaming traffic
- Explain head-of-line blocking in HTTP/1.1, HTTP/2 and HTTP/3
- Count the round trips before the first byte for each transport and TLS version
- Weigh TLS 1.3 0-RTT against its replay risk
Before you start
On this page
TCP gives an application one reliable stream of bytes in order; UDP sends separate datagrams with no promises at all. HTTP/1.1 sends one request at a time on each TCP connection, so browsers open several. HTTP/2 interleaves many requests on one TCP connection, but because TCP delivers bytes strictly in order, one lost packet stalls all of them. HTTP/3 runs over QUIC, a transport built on UDP that keeps each stream in order on its own and folds the TLS 1.3 handshake into its own, so a new connection can carry a request after one round trip. TLS 1.3 itself needs one round trip where TLS 1.2 needed two, and its 0-RTT mode saves even that one, at the price that early data can be replayed by an attacker.
This lesson measures what each choice costs: a simulation of packet loss, a round-trip count for every way of opening a connection, and the rule for what may go into early data.
TCP and UDP: what each one promises
TCP opens a connection with a handshake and then delivers a byte stream: every byte arrives, in the order it was sent, with flow control so a fast sender does not swamp a slow receiver and congestion control so senders share the network (RFC 9293). The price of “in order” is that a byte cannot be handed to the application until every byte before it has arrived.
UDP sends each message as a datagram on its own, with as little mechanism as possible: delivery and duplicate protection are not guaranteed (RFC 768). The IETF’s guidelines for UDP say plainly that an application which needs reliable delivery must build it itself, and must also cope with duplicates that may arrive much later (RFC 8085).
| Criterion | TCP | UDP |
|---|---|---|
| Before the first data | One round trip for the handshake, then TLS | Nothing: the first datagram carries data |
| Lost data | Resent by TCP | Gone, unless the application resends it |
| Ordering | One ordered byte stream per connection | None: datagrams may arrive out of order, or twice |
| Congestion control | Built in | The application's job |
| A change of network | Breaks the connection, which is its addresses and ports | No connection to break; the application copes |
| When to choose | Request-response traffic where every byte matters | DNS queries, live voice and video, game state: data that is useless if it comes late |
UDP’s natural uses are the ones where waiting is worse than losing. A DNS query and its answer fit in one datagram each, so there is no connection to set up. In a live call, audio that arrives too late to be played is useless, so the application skips it rather than waiting for a copy; the same holds for the position of a player in a game.
QUIC is the reliability work done once, carefully, on top of UDP. Its packets travel inside UDP datagrams, which existing systems and networks already carry, so it could be deployed without changing them, yet it gives back what TCP provides, reliable delivery, ordering and congestion control, adds TLS 1.3, keeps each stream in order on its own, and lets a connection survive a change of network (RFC 9000). Most traffic that needs reliability should use TCP or QUIC instead of inventing its own on raw UDP.
HTTP/1.1, HTTP/2 and HTTP/3
The meaning of HTTP, its methods, status codes and headers, is the same in all three versions. What changes is how messages travel.
- HTTP/1.1 keeps connections open for many requests by default, but on one connection it handles them one at a time. Pipelining lets a client send several requests without waiting, yet the server must return the responses in the order the requests came, so one slow response holds up the rest. Clients therefore open several connections to a server, and the standard no longer fixes how many, asking clients to be conservative (RFC 9112).
- HTTP/2 splits messages into binary frames, so many requests and responses interleave on one connection, and it compresses headers. Its own specification notes what it leaves unsolved: TCP head-of-line blocking (RFC 9113). HTTP/2’s streams are invisible to TCP, so a lost packet stalls every stream with data behind it.
- HTTP/3 maps the same semantics onto QUIC. When a packet is lost, only the streams with data in that packet wait for the copy; the others carry on (RFC 9000). Even header compression was redesigned for this: HTTP/2’s scheme assumes headers arrive in order, so HTTP/3 uses QPACK, which is built to cause far less head-of-line blocking (RFC 9204).
The same loss, delivered by TCP and by QUIC
Text description of the diagram
Two columns show the same four packets in the order they were sent. Packets 1 and 4 belong to stream A, packet 2 to stream B and packet 3 to stream C. Packet 2 is lost and resent about a round trip later.
On the left, HTTP/2 over TCP: - packet 1 is delivered; - packet 2 is lost, and TCP must deliver bytes in order, so nothing behind it can be handed to HTTP/2; - packets 3 and 4 have arrived but wait for the resent packet 2, so streams A and C stall because of a loss in stream B.
On the right, HTTP/3 over QUIC: - packet 1 is delivered; - packet 2 is lost and resent; - packets 3 and 4 are delivered to streams C and A as soon as they arrive, because QUIC keeps each stream in order on its own. Only stream B waits for its lost packet.
A server can advertise HTTP/3 with an Alt-Svc response header that names the h3 protocol, and a client that
receives it may try QUIC for its next connections. Some networks block UDP; then a client should fall back to a
TCP-based version of HTTP (RFC 9114), which is why servers keep
HTTP/2 running beside HTTP/3.
Head-of-line blocking, simulated
How much does one ordered stream cost? This simulation sends 20 responses of 10 packets each, interleaved, over a path with a 100 ms round trip, and drops packets at random. It measures when each response is complete when the receiver must deliver everything in order (HTTP/2 over TCP) and when each response is ordered on its own (HTTP/3 over QUIC). The round trip, the sending rate and the time to notice a loss are assumptions, written at the top:
"""Head-of-line blocking: 20 responses sent over one ordered byte stream (HTTP/2 over TCP) or as independent streams
(HTTP/3 over QUIC), with random packet loss. Seeded, so every run prints the same numbers."""
import random
from statistics import median, quantiles
STREAMS = 20 # 20 responses requested at once, as a page or an app screen does
PACKETS_PER_STREAM = 10 # about 12 KB each, in packets of about 1,200 bytes
RTT_MS = 100 # assumption: a round trip on a mobile network
GAP_MS = 0.5 # assumption: one packet leaves every 0.5 ms (about 19 Mbit/s)
TRIALS = 300
def completion_times(loss, rng):
"""When each stream's last byte reaches the application, for both kinds of delivery (milliseconds)."""
# The sender interleaves the streams' packets, one of each in turn, as HTTP/2 and HTTP/3 servers commonly do.
order = [(i % STREAMS, i // STREAMS) for i in range(STREAMS * PACKETS_PER_STREAM)]
arrival = []
for i in range(len(order)):
sent = i * GAP_MS
arrive = sent + RTT_MS / 2
if rng.random() < loss:
# Assumption: the loss is noticed one round trip after sending, and the copy takes half a round trip.
arrive = sent + RTT_MS + RTT_MS / 2
arrival.append(arrive)
# Ordered: TCP hands bytes to HTTP/2 strictly in order, so a packet waits for every packet sent before it.
ordered, ready = {}, 0.0
for (stream, _), arrive in zip(order, arrival):
ready = max(ready, arrive)
ordered[stream] = ready
# Independent: QUIC orders each stream on its own, so a packet waits only for its own stream's earlier packets.
independent, ready_per_stream = {}, {}
for (stream, _), arrive in zip(order, arrival):
ready_per_stream[stream] = max(ready_per_stream.get(stream, 0.0), arrive)
independent[stream] = ready_per_stream[stream]
return ordered, independent
rng = random.Random(7)
print(f"{STREAMS} responses of {PACKETS_PER_STREAM} packets, round trip {RTT_MS} ms, {TRIALS} trials per loss rate")
print("Time until a response is complete, in ms, over all responses:")
print(f"{'':>5} {'one ordered stream':>18} {'independent streams':>19}")
print(f"{'loss':>5} {'median':>8} {'p95':>9} {'median':>9} {'p95':>9} {'waited for another response':>27}")
for loss in (0.0, 0.005, 0.01, 0.02, 0.05):
ordered_all, independent_all, waited = [], [], 0
for _ in range(TRIALS):
ordered, independent = completion_times(loss, rng)
ordered_all += ordered.values()
independent_all += independent.values()
waited += sum(ordered[s] > independent[s] for s in range(STREAMS))
p95 = lambda xs: quantiles(xs, n=20)[-1]
share = waited / (TRIALS * STREAMS)
print(
f"{loss:>5.1%} {median(ordered_all):>8.0f} {p95(ordered_all):>9.0f} "
f"{median(independent_all):>9.0f} {p95(independent_all):>9.0f} {share:>27.0%}"
) Output
20 responses of 10 packets, round trip 100 ms, 300 trials per loss rate
Time until a response is complete, in ms, over all responses:
one ordered stream independent streams
loss median p95 median p95 waited for another response
0.0% 145 149 145 149 0%
0.5% 170 240 145 150 57%
1.0% 206 244 146 196 78%
2.0% 228 245 146 223 91%
5.0% 237 246 148 239 92%
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hol_blocking_sim.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With no loss the two are identical: the last packet arrives about 150 ms after the first left. With loss they part quickly. At 1 % loss, a response had only about a one-in-ten chance of losing one of its own 10 packets, yet over the ordered stream 78 % of the responses finished late, because some other response’s packet was lost ahead of theirs. The median response took 206 ms instead of 146. The independent streams’ 95th percentile also rises with loss, but only because those responses lost a packet of their own.
The model is simple on purpose: one stream’s data per packet, and every loss noticed one round trip after sending. Real QUIC packets may carry frames of several streams, and a lost packet then blocks all of them, so QUIC’s own specification advises senders to put as few streams in a packet as they can without wasting space.
Handshakes, counted in round trips
The previous lesson measured round trips of 9, 140 and 293 ms. What a new connection costs is a count of round trips times that number:
- TLS 1.2 needs two round trips for a full handshake, and one when a client resumes an earlier session (RFC 5246).
- TLS 1.3 needs one: the client’s first message carries its key share, and the server can finish its side in its first answer. A HelloRetryRequest, as in the previous lesson’s Japanese server, adds a round trip (RFC 8446).
- QUIC runs its own handshake and TLS 1.3’s together, so the client can send its request after one round trip (RFC 9001). A server that wants proof that the client really is at its address first can answer with a Retry packet, which costs one more; until the address is proven, a server may send no more than three times the bytes it has received, so it cannot be used to flood someone else (RFC 9000).
"""Round trips before the first byte of a response, for each way of opening a connection, and what they cost at the
three round-trip times measured in the previous lesson."""
# (setup, round trips spent on handshakes before the request can leave). The request and its response then cost
# one more round trip. With 0-RTT early data the request leaves with the client's first TLS message.
SETUPS = [
("TCP + TLS 1.2, full handshake", 1 + 2),
("TCP + TLS 1.2, resumed session", 1 + 1),
("TCP + TLS 1.3", 1 + 1),
("TCP + TLS 1.3 after a HelloRetryRequest", 1 + 2),
("TCP + TLS 1.3, 0-RTT early data", 1 + 0),
("QUIC, TLS 1.3 built in", 1),
("QUIC after a Retry for address checks", 1 + 1),
("QUIC, 0-RTT early data", 0),
("An open connection, reused", 0),
]
RTTS_MS = {"at 9 ms": 9, "at 140 ms": 140, "at 293 ms": 293} # near, Japan, Australia
print(f"{'setup':<42}{'round trips':>12}" + "".join(f"{name:>11}" for name in RTTS_MS))
for name, handshakes in SETUPS:
trips = handshakes + 1
print(f"{name:<42}{trips:>12}" + "".join(f"{trips * rtt:>8} ms" for rtt in RTTS_MS.values())) Output
setup round trips at 9 ms at 140 ms at 293 ms TCP + TLS 1.2, full handshake 4 36 ms 560 ms 1172 ms TCP + TLS 1.2, resumed session 3 27 ms 420 ms 879 ms TCP + TLS 1.3 3 27 ms 420 ms 879 ms TCP + TLS 1.3 after a HelloRetryRequest 4 36 ms 560 ms 1172 ms TCP + TLS 1.3, 0-RTT early data 2 18 ms 280 ms 586 ms QUIC, TLS 1.3 built in 2 18 ms 280 ms 586 ms QUIC after a Retry for address checks 3 27 ms 420 ms 879 ms QUIC, 0-RTT early data 1 9 ms 140 ms 293 ms An open connection, reused 1 9 ms 140 ms 293 ms
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 handshake_rtt.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
At 293 ms a full TLS 1.2 handshake over TCP spends 1,172 ms before the first byte, TCP with TLS 1.3 879 ms, and QUIC 586 ms. On a nearby path the same counts cost 36, 27 and 18 ms, which is why the gains of HTTP/3 and TLS 1.3 are largest for users far from the server or on slow mobile links, and small inside one data centre.
SSL Certificate Checker Check a site's certificate chain, which is what the server sends in the TLS handshake for the client to verify.0-RTT: faster, but it can be replayed
A client that connected to a server before can keep a ticket from that session. On its next connection it can resume with that key and send data in its very first message, before any round trip: 0-RTT, or early data. The first byte of the response then arrives one round trip after TCP’s handshake, or after a single round trip with QUIC.
The price is in the TLS 1.3 specification: early data is not forward secret, and there is no guarantee that it is not replayed between connections (RFC 8446). An attacker who records the first flight can send it again, and the server may act on the same request twice. QUIC’s 0-RTT has the same weakness (RFC 9001).
HTTP turns this into rules (RFC 8470):
- The client decides what to risk. Absent other information, a client may send requests with safe methods such as GET and HEAD in early data, and must not send unsafe ones. That rule is stricter than “idempotent”: PUT and DELETE are idempotent but unsafe, so they stay out.
- The server decides per resource. Some resources have side effects even on GET, so the origin server knows best.
A server can refuse early data altogether, hold a request until the handshake completes, or answer
425 Too Early, which tells the client to send the request again without early data. - Gateways pass the warning on. A proxy that forwards a request before its own handshake with the client is done
must add the header
Early-Data: 1, so the origin can still answer 425.
In a design, that means 0-RTT is worth having for read-only traffic such as page loads and catalogue reads, and is kept away from logins, payments and anything that changes state, unless the endpoint is protected against running twice in its own right.
Mobile networks and connection migration
TCP identifies a connection by the addresses and ports at both ends. When a phone moves from Wi-Fi to mobile data, its address changes and every TCP connection breaks; the app pays new handshakes and resends what was in flight. QUIC identifies a connection by connection IDs instead, so a client can move a connection to a new network path, and it also survives NAT rebinding, when a router on the path changes the address or port it uses for the device (RFC 9000). For a service whose users ride trains and buses between networks, that is a real gain on top of the faster handshake.
HAR File Analyzer & Sanitizer A HAR file records the protocol of every request, so you can see which ones used HTTP/1.1, HTTP/2 or HTTP/3.What to choose in a design
- At the edge, facing users: serve HTTP/2 and HTTP/3 with TLS 1.3, advertise HTTP/3 with
Alt-Svc, and keep the TCP versions for clients and networks that cannot use UDP. - Between services inside a data centre: long-lived HTTP/2 connections are usually enough. Round trips are short there and connections stay open for a long time, so the handshake savings of HTTP/3 matter little; reusing connections matters more.
- For 0-RTT: allow it only for safe requests whose replay does no harm, and make sure every gateway in the path
understands
425 Too Early. - For real-time media and DNS: UDP, or a protocol built on it. For anything else that needs reliability, use TCP or QUIC rather than a home-made protocol.
Interview questions
Warm-up (mid level): what is head-of-line blocking, and which versions of HTTP suffer from it? Head-of-line blocking is when one delayed item holds up everything queued behind it. HTTP/1.1 has it at the application level: responses on a connection come back in request order, so one slow response delays the rest, which is why browsers open several connections. HTTP/2 removes that by interleaving streams on one connection, but it runs over TCP, which delivers bytes in order, so one lost packet stalls every stream until it is resent. HTTP/3 runs over QUIC, which orders each stream on its own: a loss delays only the streams whose data was in the lost packet. A strong answer adds the evidence: at 1 % loss in this lesson’s simulation, 78 % of the responses on one ordered stream finished late because of another response’s loss.
Key takeaways
- TCP delivers one ordered byte stream; UDP delivers datagrams with no promises; QUIC builds reliable, encrypted, independently ordered streams on top of UDP.
- HTTP/1.1 blocks at the application level, HTTP/2 at the TCP level, and HTTP/3 only per stream.
- In the simulation, 1 % packet loss made 78 % of the responses on one ordered stream late; independent streams kept the median at 146 ms against 206 ms.
- Before the first byte: four round trips with TCP and full TLS 1.2, three with TLS 1.3, two with QUIC or with TCP and TLS 1.3 early data, and one with QUIC’s 0-RTT or a reused connection.
- 0-RTT early data can be replayed: send only safe requests in it, and let servers refuse with 425 Too Early.
- QUIC connections survive a change of network; TCP connections do not.
Exercise
Exercise · Easy · Python
Count the round trips before the first byte
Write round_trips(transport, tls, resumed=False, early_data=False) in round_trips.py. It returns how many round trips pass before the first byte of the response to an HTTPS request on a new connection:
- transport is "tcp" or "quic". TCP's handshake costs one round trip before TLS can start. QUIC runs its handshake and TLS's together, in one round trip. - tls is "1.2" or "1.3". Over TCP, a full TLS 1.2 handshake costs two round trips and a resumed TLS 1.2 session one; TLS 1.3 costs one round trip, resumed or not. QUIC always uses TLS 1.3. - early_data=True means the request rides in the client's first TLS message (0-RTT), so TLS adds no round trip of its own before the request. It needs a resumed TLS 1.3 session. - The request and its response always cost one more round trip.
Raise ValueError for anything that cannot happen: another transport or TLS version, QUIC with TLS 1.2, or early data without a resumed TLS 1.3 session.
round_trips("tcp", "1.2") # 4
round_trips("quic", "1.3", resumed=True, early_data=True) # 1
The sample tests import round_trips from round_trips.py and run in your browser.
Starter code · round_trips.py
def round_trips(transport, tls, resumed=False, early_data=False):
"""Round trips before the first byte of the response on a new connection."""
# Replace this line with your code.
return 0 The sample tests · test_round_trips.py
from round_trips import round_trips
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_tcp_and_tls_12():
"""a full TLS 1.2 handshake costs two round trips, a resumed one costs one"""
assert round_trips("tcp", "1.2") == 4
assert round_trips("tcp", "1.2", resumed=True) == 3
def test_tcp_and_tls_13():
"""TLS 1.3 costs one round trip, and none before the request with early data"""
assert round_trips("tcp", "1.3") == 3
assert round_trips("tcp", "1.3", resumed=True) == 3
assert round_trips("tcp", "1.3", resumed=True, early_data=True) == 2
def test_quic():
"""QUIC combines its handshake with TLS 1.3"""
assert round_trips("quic", "1.3") == 2
assert round_trips("quic", "1.3", resumed=True) == 2
assert round_trips("quic", "1.3", resumed=True, early_data=True) == 1
def test_impossible_combinations():
"""raises ValueError for what cannot happen"""
assert raises_value_error(lambda: round_trips("udp", "1.3"))
assert raises_value_error(lambda: round_trips("tcp", "1.1"))
assert raises_value_error(lambda: round_trips("quic", "1.2"))
assert raises_value_error(lambda: round_trips("tcp", "1.3", early_data=True))
assert raises_value_error(lambda: round_trips("tcp", "1.2", resumed=True, early_data=True)) A hint
Check the arguments first and raise ValueError for the impossible combinations. Then add up three parts: the transport's own handshake (1 for TCP, 0 for QUIC, whose handshake is counted with TLS), the TLS round trips before the request can leave (0 with early data), and 1 for the request and its response.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- RFC 9293: Transmission Control Protocol (TCP) (IETF)
- RFC 768: User Datagram Protocol (IETF)
- RFC 8085: UDP Usage Guidelines (IETF)
- RFC 9000: QUIC, A UDP-Based Multiplexed and Secure Transport (IETF)
- RFC 9001: Using TLS to Secure QUIC (IETF)
- RFC 9112: HTTP/1.1 (IETF)
- RFC 9113: HTTP/2 (IETF)
- RFC 9114: HTTP/3 (IETF)
- RFC 9204: QPACK, Field Compression for HTTP/3 (IETF)
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 (IETF)
- RFC 5246: The Transport Layer Security (TLS) Protocol Version 1.2 (IETF)
- RFC 8470: Using Early Data in HTTP (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress