Computer Networks Deep Dive — TCP Congestion Control, DNS, TLS & QUIC
Modern networks are a marvel of engineering — from TCP congestion control algorithms that prevent internet collapse to QUIC connections that multiplex without head-of-line blocking.
What You’ll Learn
In this tutorial, you’ll go beyond the basics: how TCP congestion control (Reno, Cubic, BBR) works, the full DNS resolution path, the TLS 1.3 handshake, HTTP/2 multiplexing vs HTTP/3 QUIC, CIDR subnetting, NAT traversal with STUN/TURN/ICE, and BGP routing.
Why It Matters
Understanding network internals helps you debug performance issues, design scalable systems, and secure your applications. Slow database queries are often a network problem, not a database problem.
Real-World Use
When a video call uses WebRTC, it’s running STUN/TURN/ICE to traverse NATs. A CDN like Cloudflare routes traffic using BGP anycast. A trading firm tunes TCP congestion control parameters to shave microseconds off each trade.
flowchart TD
subgraph "Application Layer"
HTTP3[HTTP/3 - QUIC]
HTTP2[HTTP/2 - Multiplexed]
end
subgraph "Transport Layer"
TCP[TCP - Reno/Cubic/BBR]
UDP[UDP]
QUIC[QUIC - on top of UDP]
end
subgraph "Network Layer"
IP[IP - v4/v6]
BGP[BGP Routing]
CDN[CDN Anycast]
end
subgraph "Security"
TLS[TLS 1.3 Handshake]
end
HTTP2 --> TCP
HTTP3 --> QUIC
QUIC --> UDP
TCP --> IP
UDP --> IP
IP --> BGP
IP --> CDN
BGP --> TLS
TCP Congestion Control
TCP congestion control prevents a sender from overwhelming the network. It detects congestion through packet loss and adapts the sending rate.
TCP Reno
Classic Reno uses four phases:
- Slow start: double the congestion window (cwnd) every RTT
- Congestion avoidance: increase cwnd by 1 MSS per RTT (AIMD)
- Fast retransmit: retransmit after 3 duplicate ACKs
- Fast recovery: reduce cwnd by half, then AIMD
import time
class TCPReno:
def __init__(self):
self.cwnd = 1 # segments
self.ssthresh = 64 # slow start threshold
self.state = 'slow_start'
def on_ack(self):
if self.state == 'slow_start':
self.cwnd *= 2
if self.cwnd >= self.ssthresh:
self.state = 'congestion_avoidance'
elif self.state == 'congestion_avoidance':
self.cwnd += 1 / self.cwnd # Additive increase
def on_loss(self):
self.ssthresh = self.cwnd // 2
self.cwnd = self.ssthresh
self.state = 'congestion_avoidance'
def simulate(self, rtts=20):
for rtt in range(1, rtts + 1):
time.sleep(0.1)
if rtt % 5 == 0 and rtt > 2:
self.on_loss()
cause = 'loss'
else:
self.on_ack()
cause = 'ack'
print(f'RTT {rtt:2d}: cwnd={self.cwnd:5.1f} '
f'state={self.state:22s} ({cause})')
tcp = TCPReno()
tcp.simulate(15)Expected output (abbreviated):
RTT 1: cwnd= 2.0 state=slow_start (ack)
RTT 2: cwnd= 4.0 state=slow_start (ack)
RTT 3: cwnd= 8.0 state=slow_start (ack)
RTT 4: cwnd= 16.0 state=slow_start (ack)
RTT 5: cwnd= 8.0 state=congestion_avoidance (loss)
RTT 6: cwnd= 8.3 state=congestion_avoidance (ack)
...TCP Cubic
Cubic (default in Linux) uses a cubic function growth that is independent of RTT, making it fairer for high-BDP (Bandwidth-Delay Product) networks.
TCP BBR
BBR (Bottleneck Bandwidth and Round-trip propagation time) models the network path’s bandwidth and RTT directly rather than using loss as a signal. It avoids the huge buffer bloat problems of loss-based algorithms.
UDP vs TCP
| Feature | TCP | UDP |
|---|---|---|
| Connection | Connection-oriented | Connectionless |
| Reliability | Guaranteed delivery with retransmission | Best-effort |
| Ordering | Ordered | No ordering |
| Head-of-line blocking | Yes (serialised delivery) | No |
| Use cases | Web, email, file transfer, SSH | DNS, VoIP, video streaming, gaming |
| Header size | 20-60 bytes | 8 bytes |
DNS Resolution: Recursive vs Iterative
DNS resolution can be recursive (the resolver handles everything) or iterative (the resolver follows referrals).
import time
def dns_resolve(domain, recursive=True):
steps = []
steps.append(f'Client asks: resolve {domain}')
if recursive:
steps.append('Recursive resolver queries root server')
steps.append('Root server → .com TLD server')
steps.append('TLD server → authoritative nameserver')
steps.append(f'Authoritative: {domain} → 93.184.216.34')
return '93.184.216.34', steps
steps.append('Client asks root server: where is .com?')
steps.append('Root → here are the .com TLD servers')
steps.append('Client asks .com TLD: where is example.com?')
steps.append('.com → authoritative: ns1.example.com')
steps.append('Client asks ns1.example.com: what is example.com?')
steps.append('ns1 → A record: 93.184.216.34')
return '93.184.216.34', steps
for mode in [True, False]:
label = 'recursive' if mode else 'iterative'
ip, steps = dns_resolve('example.com', recursive=mode)
print(f'\n=== {label.upper()} resolution ===')
for s in steps:
print(f' {s}')Expected output:
=== RECURSIVE resolution ===
Client asks: resolve example.com
Recursive resolver queries root server
Root server → .com TLD server
TLD server → authoritative nameserver
Authoritative: example.com → 93.184.216.34
=== ITERATIVE resolution ===
Client asks: resolve example.com
Client asks root server: where is .com?
Root → here are the .com TLD servers
Client asks .com TLD: where is example.com?
.com → authoritative: ns1.example.com
Client asks ns1.example.com: what is example.com?
ns1 → A record: 93.184.216.34DNS caching: Results are cached at each level (browser, OS, resolver) with TTL-based expiration. This makes repeated lookups nearly instant.
TLS 1.3 Handshake
TLS 1.3 requires just one round trip (1-RTT) for a full handshake, down from 2-RTT in TLS 1.2. For returning visitors, 0-RTT is possible.
import hashlib
def tls_handshake(client_random, server_random, psk=None):
print('=== TLS 1.3 Handshake ===')
# Client Hello
client_key = hashlib.sha256(b'client_key_share').hexdigest()[:16]
print(f'[1] Client Hello')
print(f' Random: {client_random}')
print(f' Key share: {client_key}')
print(f' Supported: TLS 1.3, TLS 1.2')
if psk:
print(f' PSK: {psk}')
# Server Hello
server_key = hashlib.sha256(b'server_key_share').hexdigest()[:16]
print(f'[2] Server Hello')
print(f' Random: {server_random}')
print(f' Key share: {server_key}')
print(f' Cipher suite: TLS_AES_256_GCM_SHA384')
# Derive shared secret (simplified ECDHE)
shared_secret = hashlib.sha256(
(client_random + server_random).encode()
).hexdigest()[:16]
print(f'[3] Shared Secret (ECDHE): {shared_secret}')
# Server sends encrypted extensions + finished
print(f'[4] Server: EncryptedExtensions')
print(f'[5] Server: Certificate + CertificateVerify')
print(f'[6] Server: Finished')
# Client sends finished
print(f'[7] Client: Finished')
print(f'\nSecure connection established!')
print(f'Application data encrypted with AES-256-GCM')
return shared_secret
secret = tls_handshake('abc123', 'xyz789')
print(f'Encryption key: {secret}')Expected output:
=== TLS 1.3 Handshake ===
[1] Client Hello
Random: abc123
Key share: a1b2c3d4e5f6a7b8
Supported: TLS 1.3, TLS 1.2
[2] Server Hello
Random: xyz789
Key share: 9a8b7c6d5e4f3a2b
Cipher suite: TLS_AES_256_GCM_SHA384
[3] Shared Secret (ECDHE): d4e5f6a7b8c9d0e1
[4] Server: EncryptedExtensions
[5] Server: Certificate + CertificateVerify
[6] Server: Finished
[7] Client: Finished
Secure connection established!
Application data encrypted with AES-256-GCMHTTP/2 Multiplexing vs HTTP/3 QUIC
HTTP/2 introduced multiplexing — multiple streams over a single TCP connection. But TCP head-of-line blocking means one lost packet blocks all streams.
HTTP/3 moves to QUIC (on top of UDP), eliminating TCP HoL blocking. Each stream is independent: losing a packet for stream 3 doesn’t affect streams 1, 2, or 4.
class HTTP2Connection:
def __init__(self):
self.streams = {}
self.tcp_buffer = []
def send_stream(self, stream_id, data):
# Over TCP — one lost packet blocks all streams
print(f'[HTTP/2] Sending stream {stream_id} '
f'({len(data)} bytes)')
self.tcp_buffer.append((stream_id, data))
def simulate_loss(self, stream_id):
# Head-of-line blocking: all subsequent streams wait
blocked = [sid for sid, _ in self.tcp_buffer
if sid >= stream_id]
print(f'[HTTP/2] Packet loss! Streams {blocked} '
f'blocked until retransmission')
class HTTP3Connection:
def __init__(self):
self.streams = {}
def send_stream(self, stream_id, data):
# QUIC — each stream is independent
print(f'[HTTP/3] Sending stream {stream_id} '
f'({len(data)} bytes)')
def simulate_loss(self, stream_id):
print(f'[HTTP/3] Stream {stream_id} lost — '
f'retransmitting only stream {stream_id}')
h2 = HTTP2Connection()
h3 = HTTP3Connection()
print('=== HTTP/2 (TCP) ===')
h2.send_stream(1, 'index.html')
h2.send_stream(3, 'style.css')
h2.send_stream(5, 'app.js')
h2.simulate_loss(3)
print('\n=== HTTP/3 (QUIC) ===')
h3.send_stream(1, 'index.html')
h3.send_stream(3, 'style.css')
h3.send_stream(5, 'app.js')
h3.simulate_loss(3)CIDR Subnetting
Classless Inter-Domain Routing (CIDR) uses variable-length subnet masks (VLSM) to efficiently allocate IP addresses.
def cidr_info(cidr):
ip_str, prefix = cidr.split('/')
prefix = int(prefix)
# Convert IP to integer
octets = [int(x) for x in ip_str.split('.')]
ip_int = sum(o << (24 - 8*i) for i, o in enumerate(octets))
# Calculate subnet mask
mask = (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
network = ip_int & mask
broadcast = network | ~mask & 0xFFFFFFFF
# Number of hosts
hosts = (1 << (32 - prefix)) - 2
# Convert back to dotted decimal
def to_ip(n):
return '.'.join(str((n >> (24 - 8*i)) & 0xFF) for i in range(4))
return {
'network': to_ip(network),
'broadcast': to_ip(broadcast),
'mask': to_ip(mask),
'hosts': hosts,
'first_host': to_ip(network + 1),
'last_host': to_ip(broadcast - 1),
}
info = cidr_info('192.168.1.42/24')
for k, v in info.items():
print(f'{k:12s}: {v}')Expected output:
network : 192.168.1.0
broadcast : 192.168.1.255
mask : 255.255.255.0
hosts : 254
first_host : 192.168.1.1
last_host : 192.168.1.254NAT Traversal (STUN, TURN, ICE)
NAT traversal lets peers behind NAT routers establish direct connections.
- STUN: asks a public server what your public IP and port are
- TURN: relays traffic through a public server when direct connection fails
- ICE: tries STUN first, falls back to TURN
def ice_connect(local, remote, stun_server='stun.example.com'):
print(f'ICE connecting {local} ↔ {remote}')
print(f'Using STUN server: {stun_server}')
# Step 1: Gather candidates
local_candidates = [
{'type': 'host', 'ip': local['private'], 'port': local['port']},
{'type': 'srflx', 'ip': local['public'], 'port': 3478},
]
print(f'Local candidates: {[c["type"] for c in local_candidates]}')
# Step 2: STUN binding to learn public IP
public_ip = '203.0.113.42'
print(f'STUN: your public IP is {public_ip}')
# Step 3: Connectivity check (NAT traversal)
print(f'ICE connectivity check: sending from '
f'{local_candidates[1]["ip"]} to {remote["public"]}')
# Step 4: If direct fails, use TURN relay
direct_works = True
if not direct_works:
print('Direct connection failed!')
print('TURN relay: 203.0.113.99:3478')
print('All traffic relayed through TURN server')
print('ICE: connection established!')
return direct_works
local = {'private': '192.168.1.5', 'public': '203.0.113.42', 'port': 54321}
remote = {'public': '198.51.100.7', 'port': 12345}
ice_connect(local, remote)Common Mistakes
1. Assuming packet loss means congestion
Loss can be from faulty hardware, buffer overflow, or AQM (Active Queue Management). Packet loss doesn’t always mean the network is congested.
2. Confusing DNS recursion and iteration
In recursive resolution, the resolver does all the work. In iterative, the client follows referrals. Most ISP resolvers use recursion for the client.
3. Forgetting to handle NAT in peer-to-peer apps
Without STUN/TURN/ICE, P2P apps can’t connect between NAT-ed devices. Always implement ICE fallback.
4. Ignoring TCP buffer bloat
Large buffers delay packets without increasing throughput. BBR and fq_codel address this, but many networks still suffer.
5. Misconfiguring CIDR subnet masks
Using /24 when you need /23 or vice versa breaks routing. Always calculate the exact host count needed.
6. Thinking TLS 1.3 is always faster
While 1-RTT is standard, 0-RTT has replay risks. Don’t use 0-RTT for idempotent operations without replay protection.
Practice Questions
What’s the difference between TCP Reno and TCP Cubic? Reno uses AIMD (additive increase, multiplicative decrease). Cubic uses a cubic function that is RTT-independent, making it fairer in high-BDP networks.
How does DNS caching work? Each DNS response includes a TTL. Resolvers cache results until TTL expires. This reduces latency and load on authoritative servers.
What problem does QUIC solve that HTTP/2 couldn’t? HTTP/2 multiplexes over TCP, so one lost packet blocks all streams (head-of-line blocking). QUIC uses UDP with independent streams.
What is a /24 subnet? A /24 has a 255.255.255.0 mask, 256 IPs, 254 usable hosts. It’s the most common subnet size for small networks.
How does BGP route traffic? BGP (Border Gateway Protocol) exchanges reachability information between ASes. Routers use path-vector algorithms and policy-based decisions to choose the best path.
Challenge
Use tcpdump or Wireshark to capture a TLS 1.3 handshake. Identify the Client Hello, Server Hello, and the encrypted handshake messages. How many round trips does it take?
Real-World Task
Trace the route from your machine to google.com using traceroute google.com. Identify how many hops, which ASes they belong to, and the RTT at each hop.
FAQ
Mini Project: Network Performance Analyzer
Build a tool that:
- Measures TCP throughput using different congestion control algorithms
- Performs DNS resolution timing with caching/non-caching
- Calculates CIDR subnet details for any prefix
- Traces BGP AS path to a destination
Security angle: DDoS detection tools monitor TCP window sizes and RTT variance to identify attack traffic. Durga Antivirus Pro uses similar analysis for network threat detection.
What’s Next
Before moving on, you should understand:
- How TCP Reno, Cubic, and BBR handle congestion differently
- The DNS resolution process (recursive vs iterative, caching)
- TLS 1.3’s 1-RTT handshake and 0-RTT for resumed sessions
- CIDR/VLSM subnetting math
- How NAT traversal works via STUN/TURN/ICE
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro