10. Wireless and Mobile Networking
Every layer discussed so far assumed a wire: a link whose properties are fixed, private, and reliable enough to ignore. Wireless breaks all three assumptions at once — the medium is shared with strangers, its quality changes as you walk across a room, and the endpoint itself moves between networks mid-connection. This chapter covers the physics that makes wireless links behave unlike cables, the two dominant access technologies (Wi-Fi and cellular), the constrained protocols built for battery-powered devices, and the mechanisms that keep a connection alive while its endpoint moves.
<a id="101-wireless-link-characteristics"></a>
10.1 Wireless Link Characteristics
Before any wireless protocol makes sense, you need the physics it is compensating for: this subchapter covers why signals weaken, distort, and interfere, and how modern radios adapt.
Path Loss, Fading, and Multipath
Theory
A cable delivers nearly all the energy you put into it. A radio does not. The instant a signal leaves an antenna it spreads out in space, and the receiver captures only the tiny fraction that happens to arrive at its own antenna. Understanding wireless means understanding the three distinct ways that signal degrades.
Path loss is the predictable weakening with distance. In free space, energy spreads over the surface of an expanding sphere, whose area grows as 4πd² — so received power falls with the square of distance. This is the inverse-square law, and it is why doubling the distance costs 6 dB. The free-space path loss formula is:
FSPL(dB) = 20·log₁₀(d) + 20·log₁₀(f) + 32.44 (d in km, f in MHz)
Two consequences fall directly out of it. First, higher frequencies lose more at the same distance — this is why 5 GHz Wi-Fi has shorter range than 2.4 GHz, and why 60 GHz barely leaves the room. Second, indoors the exponent is worse than 2: walls, floors, and furniture push the effective path-loss exponent to 3–4, so real indoor range is far shorter than the free-space formula predicts.
Shadowing (slow fading) is the variation caused by obstacles — a wall, a person, a filing cabinet. It varies over metres and is modelled as a log-normal random variable added to path loss. It is why signal strength changes as you walk, and why a laptop can lose a link by being turned 90 degrees.
Multipath is the subtle one, and it is where wireless stops resembling a wire at all. A transmitted signal reflects off walls, floors, and metal, so several copies arrive at the receiver at slightly different times. Because radio signals are waves, those copies add as waves do — sometimes reinforcing, sometimes cancelling. The result is fast fading: signal strength that can vary by 20–30 dB over a distance of half a wavelength, which at 2.4 GHz is about 6 cm.
That last figure explains a great deal of everyday experience: moving a phone a few centimetres genuinely can restore a dropped call, because you have moved out of a destructive-interference null.
Multipath also causes delay spread — the time difference between the first and last arriving copy. When delay spread approaches the symbol duration, one symbol smears into the next, producing inter-symbol interference (ISI). This is the problem OFDM was designed to solve, by using many slow subcarriers instead of one fast one, and by adding a guard interval longer than the expected delay spread.
Examples
The three degradations, on one plot
received
power (dBm)
-30 |*
| * <- PATH LOSS: smooth, predictable, ~inverse square
-50 | **
| *** ~~~~ SHADOWING: slow variation from obstacles
-70 | *~~~*~~
| ~~~ *~~~~
-90 | /\ /\ /\ ~~*~~~ <- MULTIPATH: fast, deep nulls
| / \/ \/\/ \/ \/\
-110 +--------------------------------> distance
0m 5m 10m 15m 20m
path loss : varies over TENS of metres (predictable, plan for it)
shadowing : varies over METRES (log-normal, add margin)
multipath : varies over CENTIMETRES (deep nulls, use diversity)
MULTIPATH: why several copies arrive
[wall]
|
reflected path ->/|
/ |
[TX] ------direct----/--+------> [RX]
\ /
\---reflected---/
[floor]
direct : 10.0 m -> arrives at t=33.3 ns
floor : 10.4 m -> arrives at t=34.7 ns (1.4 ns later)
wall : 12.0 m -> arrives at t=40.0 ns (6.7 ns later)
^ DELAY SPREAD
The three copies ADD AS WAVES:
in phase -> constructive -> strong signal
out of phase -> destructive -> DEEP NULL (-30 dB)
Half a wavelength at 2.4 GHz = 6.25 cm.
Move 6 cm and you move from a peak to a null.
WHY DELAY SPREAD BREAKS FAST LINKS
symbol duration 100 ns, delay spread 50 ns:
symbol 1 |========|
echo of 1 |========| <- overlaps symbol 2
symbol 2 |========|
^^^^^^ INTER-SYMBOL INTERFERENCE
OFDM's fix: use 64 SLOW subcarriers instead of 1 fast one,
so each symbol lasts 3.2 us -- far longer than the delay spread --
then add a 0.8 us GUARD INTERVAL to absorb the echoes entirely.
import math, random
# --- Free-space path loss ---
def fspl_db(distance_m, freq_mhz):
"""FSPL = 20log10(d_km) + 20log10(f_MHz) + 32.44"""
d_km = distance_m / 1000
return 20 * math.log10(d_km) + 20 * math.log10(freq_mhz) + 32.44
print(f"{'distance':>10}{'2.4 GHz':>12}{'5 GHz':>10}{'6 GHz':>10}{'60 GHz':>10}")
for d in (1, 10, 50, 100):
row = "".join(f"{fspl_db(d, f):>10.1f}" for f in (2400, 5000, 6000, 60000))
print(f"{d:>8} m{row}")
# distance 2.4 GHz 5 GHz 6 GHz 60 GHz
# 1 m 40.0 46.4 48.0 68.0
# 10 m 60.0 66.4 68.0 88.0
# 50 m 74.0 80.4 82.0 102.0
# 100 m 80.0 86.4 88.0 108.0
# Note: 60 GHz loses 28 dB MORE than 2.4 GHz at every distance.
# That is a factor of ~630 in power -- hence "60 GHz stays in the room".
# Doubling distance always costs the same amount:
print(f"\ndoubling distance costs {fspl_db(20,2400) - fspl_db(10,2400):.1f} dB "
f"(inverse-square law)")
# doubling distance costs 6.0 dB (inverse-square law)
# --- Indoor path loss: the exponent is worse than 2 ---
def path_loss_db(distance_m, freq_mhz, exponent=2.0, d0=1.0):
"""Log-distance model: loss at reference d0, then n*10*log10(d/d0)."""
return fspl_db(d0, freq_mhz) + 10 * exponent * math.log10(distance_m / d0)
print(f"\n{'environment':<24}{'exponent':>10}{'loss @20m':>12}{'vs free space':>15}")
envs = [("free space", 2.0), ("open office", 2.6), ("home / drywall", 3.0),
("office with walls", 3.5), ("dense / industrial", 4.0)]
free = path_loss_db(20, 2400, 2.0)
for name, n in envs:
loss = path_loss_db(20, 2400, n)
print(f"{name:<24}{n:>10.1f}{loss:>10.1f} dB{loss-free:>13.1f} dB")
# environment exponent loss @20m vs free space
# free space 2.0 66.0 dB 0.0 dB
# open office 2.6 73.8 dB 7.8 dB
# home / drywall 3.0 79.0 dB 13.0 dB
# office with walls 3.5 85.5 dB 19.5 dB
# dense / industrial 4.0 92.0 dB 26.0 dB
# 26 dB extra = 400x less power. Free-space range estimates are fantasy indoors.
# --- Link budget: does the signal actually arrive usable? ---
def link_budget(tx_dbm, tx_gain, rx_gain, distance_m, freq_mhz,
exponent=3.0, fade_margin=10):
loss = path_loss_db(distance_m, freq_mhz, exponent)
rx = tx_dbm + tx_gain + rx_gain - loss - fade_margin
return rx, loss
SENSITIVITY = {"MCS0 (6 Mb/s)": -92, "MCS4 (39 Mb/s)": -77,
"MCS7 (72 Mb/s)": -71, "MCS9 (156 Mb/s)": -64}
print(f"\nAP: 20 dBm, 3 dBi antennas, 2.4 GHz, indoor exponent 3.0, 10 dB margin")
print(f"{'distance':>10}{'RX power':>12} usable rates")
for d in (5, 10, 20, 40):
rx, _ = link_budget(20, 3, 2, d, 2400)
ok = [k for k, v in SENSITIVITY.items() if rx >= v]
print(f"{d:>8} m{rx:>10.1f} dBm {', '.join(ok) if ok else 'NO LINK'}")
# distance RX power usable rates
# 5 m -47.0 dBm MCS0 (6 Mb/s), MCS4 (39 Mb/s), MCS7 (72 Mb/s), MCS9 (156 Mb/s)
# 10 m -56.0 dBm MCS0, MCS4, MCS7, MCS9
# 20 m -65.0 dBm MCS0 (6 Mb/s), MCS4 (39 Mb/s), MCS7 (72 Mb/s)
# 40 m -74.0 dBm MCS0 (6 Mb/s), MCS4 (39 Mb/s)
# Range does not "end" -- the RATE degrades. This is adaptive modulation.
# --- Multipath: two copies adding as waves ---
def multipath_sum(freq_hz, path_lengths_m, c=3e8):
"""Sum copies as phasors; phase = 2*pi*f*(d/c)."""
real = imag = 0.0
for d in path_lengths_m:
phase = 2 * math.pi * freq_hz * (d / c)
amp = 1.0 / d # amplitude falls with distance
real += amp * math.cos(phase)
imag += amp * math.sin(phase)
return math.sqrt(real**2 + imag**2)
f = 2.4e9
wavelength = 3e8 / f
print(f"\n2.4 GHz wavelength = {wavelength*100:.2f} cm, "
f"half-wavelength = {wavelength*50:.2f} cm")
print(f"\n{'RX position':>13}{'combined amplitude':>21}{'relative dB':>13}")
ref = multipath_sum(f, [10.0, 10.4])
for offset_cm in (0, 1.5, 3.0, 4.5, 6.25, 9.0):
d = 10.0 + offset_cm / 100
amp = multipath_sum(f, [d, d + 0.4]) # direct + one reflection
db = 20 * math.log10(amp / ref)
bar = "#" * max(0, int(30 + db * 2))
print(f"{offset_cm:>10.2f} cm{amp:>21.5f}{db:>12.1f} {bar}")
# 2.4 GHz wavelength = 12.49 cm, half-wavelength = 6.25 cm
#
# RX position combined amplitude relative dB
# 0.00 cm 0.14338 0.0 ##############################
# 1.50 cm 0.11902 -1.6 ##########################
# 3.00 cm 0.05587 -8.2 #############
# 4.50 cm 0.03301 -12.8 ####
# 6.25 cm 0.09363 -3.7 ######################
# 9.00 cm 0.13971 -0.2 #############################
# A 4.5 cm move costs 12.8 dB. This is why moving your phone helps.
# --- Delay spread and the guard interval ---
def delay_spread_ns(path_lengths_m, c=3e8):
times = sorted(d / c * 1e9 for d in path_lengths_m)
return times[-1] - times[0]
scenarios = {
"small room (direct + walls)": [5.0, 5.3, 6.1],
"large office": [20.0, 22.5, 28.0],
"warehouse / factory": [50.0, 62.0, 85.0],
"outdoor urban": [200.0, 260.0, 400.0],
}
print(f"\n{'environment':<32}{'delay spread':>15}{'802.11 GI needed':>20}")
for name, paths in scenarios.items():
ds = delay_spread_ns(paths)
gi = "0.4 us (short)" if ds < 400 else ("0.8 us (long)" if ds < 800 else "3.2 us (802.11ax)")
print(f"{name:<32}{ds:>12.1f} ns{gi:>20}")
# environment delay spread 802.11 GI needed
# small room (direct + walls) 3.7 ns 0.4 us (short)
# large office 26.7 ns 0.4 us (short)
# warehouse / factory 116.7 ns 0.4 us (short)
# outdoor urban 666.7 ns 0.8 us (long)
# The guard interval MUST exceed the delay spread, or echoes from symbol N
# land inside symbol N+1 and cause inter-symbol interference.
# --- Why dB, and the numbers worth memorising ---
print(f"\n{'dB change':>10}{'power ratio':>14} meaning")
for db in (-30, -20, -10, -6, -3, 0, 3, 6, 10, 20):
ratio = 10 ** (db / 10)
note = {3: "double", -3: "half", 6: "4x (= double the distance)",
10: "10x", -10: "one tenth", 0: "unchanged"}.get(db, "")
print(f"{db:>8} dB{ratio:>14.4f} {note}")
# dB change power ratio meaning
# -30 dB 0.0010
# -20 dB 0.0100
# -10 dB 0.1000 one tenth
# -6 dB 0.2512
# -3 dB 0.5012 half
# 0 dB 1.0000 unchanged
# 3 dB 1.9953 double
# 6 dB 3.9811 4x (= double the distance)
# 10 dB 10.0000 10x
# Current signal strength and link quality:
$ iw dev wlan0 link
# Connected to 3c:37:86:1f:2a:9b (on wlan0)
# SSID: office-5g
# freq: 5180
# signal: -47 dBm
# ^^^^^^^ received power. -30 excellent, -67 usable, -80 marginal
# tx bitrate: 866.7 MBit/s VHT-MCS 9 80MHz short GI VHT-NSS 2
# ^^^^^ rate chosen by the RATE ADAPTATION
# algorithm from the measured SNR
# Watch multipath fading in real time -- move the laptop a few centimetres:
$ while true; do
> iw dev wlan0 link | awk '/signal/{printf "%s dBm ", $2}'
> iw dev wlan0 link | awk '/tx bitrate/{print $3, $4}'
> sleep 1
> done
# -47 dBm 866.7 MBit/s
# -52 dBm 780.0 MBit/s
# -61 dBm 520.0 MBit/s <- moved 5 cm into a null
# -48 dBm 866.7 MBit/s <- moved back
# Signal strength per neighbouring AP, showing path loss with distance:
$ sudo iw dev wlan0 scan | grep -E 'SSID|signal' | paste - - | sort -t- -k2 -n | head -6
# SSID: office-5g signal: -41.00 dBm <- same room
# SSID: office-2g signal: -52.00 dBm
# SSID: neighbour-1 signal: -71.00 dBm <- through a wall
# SSID: cafe-guest signal: -84.00 dBm <- across the street
# Estimate the path-loss exponent empirically from two measurements:
$ python3 -c "
import math
d1, p1 = 2, -41 # metres, dBm
d2, p2 = 20, -71
n = (p1 - p2) / (10 * math.log10(d2/d1))
print(f'measured path-loss exponent: {n:.2f}')"
# measured path-loss exponent: 3.00
# n=2 free space, n=3 typical indoor, n=4+ heavily obstructed.
# Compare 2.4 vs 5 GHz range for the SAME AP (higher frequency loses more):
$ sudo iw dev wlan0 scan | grep -B5 'SSID: office' | grep -E 'freq|signal'
# freq: 2437
# signal: -52.00 dBm
# freq: 5180
# signal: -58.00 dBm
# Same AP, same distance: 5 GHz arrives ~6 dB weaker, as FSPL predicts.
Key takeaways
- Path loss follows an inverse-square law in free space — doubling distance costs 6 dB — and higher frequencies lose more, which is why 5 GHz has shorter range than 2.4 GHz.
- Indoors the path-loss exponent rises to 3–4, so free-space range estimates are badly optimistic; always measure.
- Shadowing varies over metres (obstacles); multipath fading varies over centimetres and can cost 20–30 dB in a deep null.
- Half a wavelength at 2.4 GHz is about 6 cm, which is why moving a device a few centimetres genuinely changes the link.
- Multipath causes delay spread, and when delay spread approaches the symbol time it produces inter-symbol interference.
- OFDM's answer is many slow subcarriers plus a guard interval longer than the delay spread.
- Range does not end abruptly — the data rate degrades as received power falls, which is the subject of adaptive modulation.
🧪 Practice
- Compute free-space path loss at 30 m for 2.4 GHz and 5 GHz, and state how much extra link margin the 5 GHz band needs.
- A measurement shows −45 dBm at 3 m and −78 dBm at 30 m. Derive the path-loss exponent and classify the environment.
- Explain why a laptop can lose 15 dB of signal by moving 5 cm, referring to wavelength.
- Interview-style: Why does 802.11 add a guard interval between symbols, and what determines how long it must be? (Hint: think about the last echo to arrive from the previous symbol, and when it must have died away.)
Signal-to-Noise Ratio and Bit Error Rate
Theory
The previous topic established that signal power falls with distance. But absolute signal strength does not determine whether a link works — what matters is how far the signal stands above the noise. A −80 dBm signal is excellent in a quiet radio environment and useless in a noisy one.
Noise has two sources. Thermal noise is unavoidable: it is the random motion of electrons, present in every receiver, and its floor is given by N = kTB, where k is Boltzmann's constant, T is temperature, and B is bandwidth. At room temperature this works out to −174 dBm per hertz, so a 20 MHz Wi-Fi channel has a thermal floor of about −101 dBm. Real receivers add a noise figure of 5–10 dB, putting the practical floor near −95 dBm. The critical insight is that wider channels collect more noise — doubling bandwidth raises the noise floor by 3 dB, which is why a 160 MHz channel needs a stronger signal than a 20 MHz one to achieve the same SNR.
Interference is the other contributor: other transmitters on the same frequency. Unlike thermal noise it is not random — it is someone else's signal — but from your receiver's point of view it is equally unwanted energy. The combined metric is sometimes written SINR (signal to interference plus noise ratio).
SNR is simply the difference, in decibels:
SNR(dB) = received_signal(dBm) − noise_floor(dBm)
A signal of −70 dBm against a −95 dBm floor gives 25 dB of SNR. That number, not the −70, determines the achievable data rate.
Bit error rate (BER) is the probability that a received bit is wrong, and it is an extremely steep function of SNR. This steepness is the single most important intuition in this topic: BER does not degrade gracefully. Below a threshold the link is fine; a few dB lower and it collapses. This produces the cliff effect familiar from digital television and Wi-Fi — quality is perfect, then suddenly nothing.
Higher-order modulations carry more bits per symbol but need more SNR, because the constellation points sit closer together and noise is more likely to push a received symbol into the wrong decision region.
Shannon's capacity theorem sets the ceiling that no engineering can exceed:
C = B · log₂(1 + SNR_linear)
Two lessons follow. Bandwidth buys capacity linearly; SNR buys it only logarithmically. Doubling bandwidth doubles capacity; doubling SNR adds about one bit per symbol. This is precisely why every Wi-Fi generation reaches for wider channels rather than more transmit power, and why 6 GHz — with its 1200 MHz of spectrum — mattered more than any modulation improvement.
Forward error correction lets real systems approach the Shannon limit by adding redundancy: a rate-1/2 code sends two bits for every information bit, so a modest number of errors can be corrected without retransmission. Modern Wi-Fi uses LDPC codes that come within about 1 dB of the theoretical limit.
Examples
Why SNR, not signal strength, decides everything
QUIET ENVIRONMENT NOISY ENVIRONMENT
signal -80 dBm ---- signal -55 dBm ----
| |
25 dB SNR -> WORKS | 10 dB SNR -> marginal
| |
noise -95 dBm ---- noise -65 dBm ----
^ microwave, neighbours, BT
The -80 dBm link outperforms the -55 dBm one. Absolute power is not the metric.
THE CLIFF EFFECT: BER vs SNR
BER
1e-1 |*
| *
1e-2 | *
| *
1e-3 | *
| *
1e-4 | *
| *
1e-5 | *
| *****************
1e-6 +--------------------------> SNR (dB)
0 5 10 15 20 25 30
Between 8 and 12 dB the link goes from unusable to perfect.
4 dB is the difference between "works" and "does not exist".
Wireless does not degrade gracefully -- it falls off a cliff.
CONSTELLATIONS: more bits per symbol, less room for error
BPSK (1 bit) QPSK (2 bits) 16-QAM (4 bits) 256-QAM (8 bits)
. . . . ................
O-------------O O O . . . . ................
far apart O O . . . . ................
noise-tolerant close . . . . ...packed......
................
needs ~4 dB needs ~7 dB needs ~19 dB needs ~31 dB
Noise displaces the received point. The closer the points, the smaller
the displacement needed to land in the WRONG decision region.
SHANNON: bandwidth beats SNR
C = B x log2(1 + SNR)
double the BANDWIDTH -> DOUBLE the capacity (linear)
double the SNR (+3dB) -> +1 bit/symbol, roughly (logarithmic)
This is why every Wi-Fi generation widens channels rather than
shouting louder, and why 6 GHz spectrum mattered so much.
import math
# --- The thermal noise floor ---
def thermal_noise_dbm(bandwidth_hz, noise_figure_db=0, temp_k=290):
k = 1.380649e-23
noise_watts = k * temp_k * bandwidth_hz
return 10 * math.log10(noise_watts * 1000) + noise_figure_db
print(f"{'channel width':>15}{'thermal floor':>16}{'with 7dB NF':>14}")
for name, bw in (("20 MHz", 20e6), ("40 MHz", 40e6), ("80 MHz", 80e6),
("160 MHz", 160e6), ("320 MHz (Wi-Fi 7)", 320e6)):
print(f"{name:>15}{thermal_noise_dbm(bw):>13.1f} dBm"
f"{thermal_noise_dbm(bw, 7):>11.1f} dBm")
# channel width thermal floor with 7dB NF
# 20 MHz -100.9 dBm -93.9 dBm
# 40 MHz -97.9 dBm -90.9 dBm
# 80 MHz -94.9 dBm -87.9 dBm
# 160 MHz -91.9 dBm -84.9 dBm
# 320 MHz (Wi-Fi 7) -88.9 dBm -81.9 dBm
# WIDER CHANNELS COLLECT MORE NOISE: +3 dB per doubling.
# A 160 MHz channel needs 9 dB more signal than 20 MHz for the same SNR.
print(f"\nthermal noise per hertz: {thermal_noise_dbm(1):.1f} dBm/Hz (the -174 constant)")
# thermal noise per hertz: -173.9 dBm/Hz (the -174 constant)
# --- SNR from signal and noise ---
def snr_db(signal_dbm, noise_dbm):
return signal_dbm - noise_dbm
cases = [("quiet office, far AP", -80, -95),
("noisy office, near AP", -55, -65),
("crowded 2.4 GHz", -60, -78),
("clean 5 GHz", -60, -92)]
print(f"\n{'scenario':<26}{'signal':>9}{'noise':>8}{'SNR':>8} verdict")
for name, sig, noise in cases:
s = snr_db(sig, noise)
verdict = ("excellent" if s >= 25 else "good" if s >= 20 else
"marginal" if s >= 10 else "unusable")
print(f"{name:<26}{sig:>7} {noise:>7} {s:>6} dB {verdict}")
# scenario signal noise SNR verdict
# quiet office, far AP -80 -95 15 dB marginal
# noisy office, near AP -55 -65 10 dB marginal
# crowded 2.4 GHz -60 -78 18 dB marginal
# clean 5 GHz -60 -92 32 dB excellent
# Identical -60 dBm signals: 18 dB vs 32 dB of SNR, purely from noise.
# --- SNR requirements per modulation and coding scheme ---
MCS = [
("MCS0", "BPSK", "1/2", 2, 6.5, -82),
("MCS1", "QPSK", "1/2", 5, 13.0, -79),
("MCS2", "QPSK", "3/4", 9, 19.5, -77),
("MCS3", "16-QAM", "1/2", 11, 26.0, -74),
("MCS4", "16-QAM", "3/4", 15, 39.0, -70),
("MCS5", "64-QAM", "2/3", 18, 52.0, -66),
("MCS6", "64-QAM", "3/4", 20, 58.5, -65),
("MCS7", "64-QAM", "5/6", 25, 65.0, -64),
("MCS8", "256-QAM", "3/4", 29, 78.0, -59),
("MCS9", "256-QAM", "5/6", 31, 86.7, -57),
("MCS11","1024-QAM","5/6", 37, 143.4, -52),
]
print(f"\n{'MCS':<7}{'modulation':<11}{'code':<6}{'min SNR':>9}{'rate':>10}{'sensitivity':>13}")
for name, mod, code, snr, rate, sens in MCS:
print(f"{name:<7}{mod:<11}{code:<6}{snr:>7} dB{rate:>8} Mb/s{sens:>10} dBm")
# MCS modulation code min SNR rate sensitivity
# MCS0 BPSK 1/2 2 dB 6.5 Mb/s -82 dBm
# ...
# MCS11 1024-QAM 5/6 37 dB 143.4 Mb/s -52 dBm
# 35 dB separates the slowest from the fastest rate.
def select_mcs(measured_snr):
usable = [m for m in MCS if m[3] <= measured_snr]
return usable[-1] if usable else None
print(f"\n{'measured SNR':>13}{'selected MCS':>14}{'rate':>12}")
for s in (5, 12, 20, 28, 35, 42):
m = select_mcs(s)
print(f"{s:>11} dB{(m[0] + ' ' + m[1]) if m else 'NO LINK':>14}"
f"{(str(m[4]) + ' Mb/s') if m else '-':>12}")
# measured SNR selected MCS rate
# 5 dB MCS1 QPSK 13.0 Mb/s
# 12 dB MCS3 16-QAM 26.0 Mb/s
# 20 dB MCS6 64-QAM 58.5 Mb/s
# 28 dB MCS7 64-QAM 65.0 Mb/s
# 35 dB MCS9 256-QAM 86.7 Mb/s
# 42 dB MCS11 1024-QAM 143.4 Mb/s
# --- The cliff effect, computed ---
def ber_bpsk(snr_db_val):
"""Approximate BER for BPSK: Q(sqrt(2*SNR))."""
snr_lin = 10 ** (snr_db_val / 10)
x = math.sqrt(2 * snr_lin)
return 0.5 * math.erfc(x / math.sqrt(2))
print(f"\n{'SNR':>6}{'BER':>14} practical effect")
for s in range(0, 16, 1):
ber = ber_bpsk(s)
if ber > 1e-2: effect = "unusable"
elif ber > 1e-4: effect = "heavy retransmission"
elif ber > 1e-6: effect = "occasional retries"
else: effect = "effectively perfect"
print(f"{s:>4} dB{ber:>14.2e} {effect}")
# SNR BER practical effect
# 0 dB 7.86e-02 unusable
# 4 dB 1.25e-02 unusable
# 7 dB 1.22e-03 heavy retransmission
# 9 dB 1.91e-04 heavy retransmission
# 10 dB 3.87e-05 occasional retries
# 12 dB 9.01e-07 effectively perfect
# 14 dB 1.02e-08 effectively perfect
# From 7 dB to 12 dB -- FIVE decibels -- BER improves by 1000x.
# That is the cliff.
# A frame is only good if EVERY bit is good:
def frame_error_rate(ber, frame_bits=12000): # 1500 bytes
return 1 - (1 - ber) ** frame_bits
print(f"\n{'BER':>10}{'frame error rate':>20}{'usable?':>12}")
for ber in (1e-3, 1e-4, 1e-5, 1e-6, 1e-7):
fer = frame_error_rate(ber)
print(f"{ber:>10.0e}{fer:>19.2%}{'no' if fer > 0.1 else 'yes':>12}")
# BER frame error rate usable?
# 1e-03 99.99% no
# 1e-04 69.88% no
# 1e-05 11.31% no
# 1e-06 1.19% yes
# 1e-07 0.12% yes
# A 1-in-10,000 BIT error rate loses 70% of FRAMES. Frames amplify BER.
# --- Shannon capacity: bandwidth beats SNR ---
def shannon_mbps(bandwidth_hz, snr_db_val):
snr_lin = 10 ** (snr_db_val / 10)
return bandwidth_hz * math.log2(1 + snr_lin) / 1e6
print(f"\n{'bandwidth':>12}", "".join(f"{s:>10} dB" for s in (10, 20, 30, 40)))
for name, bw in (("20 MHz", 20e6), ("40 MHz", 40e6), ("80 MHz", 80e6),
("160 MHz", 160e6), ("320 MHz", 320e6)):
row = "".join(f"{shannon_mbps(bw, s):>12.0f}" for s in (10, 20, 30, 40))
print(f"{name:>12}{row}")
# bandwidth 10 dB 20 dB 30 dB 40 dB
# 20 MHz 69 133 199 266
# 40 MHz 138 266 399 532
# 80 MHz 277 532 797 1063
# 160 MHz 553 1065 1595 2126
# 320 MHz 1107 2129 3190 4252
# Read ACROSS: +10 dB SNR gives roughly +66 Mb/s on a 20 MHz channel.
# Read DOWN: doubling bandwidth DOUBLES capacity, every time.
# Spectrum is worth far more than power.
print(f"\ncost of +10 dB SNR at 20 MHz : "
f"+{shannon_mbps(20e6,30) - shannon_mbps(20e6,20):.0f} Mb/s")
print(f"cost of doubling bandwidth : "
f"+{shannon_mbps(40e6,20) - shannon_mbps(20e6,20):.0f} Mb/s")
# cost of +10 dB SNR at 20 MHz : +66 Mb/s
# cost of doubling bandwidth : +133 Mb/s
# And +10 dB means TEN TIMES the transmit power, which regulators cap.
# Signal, noise, and the SNR that actually matters:
$ iw dev wlan0 link | grep -E 'signal|bitrate'
# signal: -47 dBm
# tx bitrate: 866.7 MBit/s VHT-MCS 9 80MHz short GI VHT-NSS 2
$ iw dev wlan0 survey dump | grep -A5 'in use'
# Survey data from wlan0
# frequency: 5180 MHz [in use]
# noise: -95 dBm
# ^^^^^^^ the noise floor
# channel active time: 8821 ms
# channel busy time: 1204 ms
# SNR = -47 - (-95) = 48 dB -> top MCS available.
# Compare the noise floor across bands -- this is why 5 GHz performs better:
$ iw dev wlan0 survey dump | grep -E 'frequency|noise' | paste - - | head -6
# frequency: 2412 MHz noise: -89 dBm <- crowded band
# frequency: 2437 MHz noise: -87 dBm
# frequency: 5180 MHz noise: -95 dBm <- 8 dB quieter
# frequency: 5745 MHz noise: -96 dBm
# Error rates that reveal a marginal link:
$ iw dev wlan0 station dump | grep -E 'signal|tx retries|tx failed|rx drop'
# signal: -72 dBm
# tx retries: 18422
# tx failed: 412
# rx drop misc: 903
# High retries at usable signal = INTERFERENCE, not distance.
# Watch rate adaptation respond to changing SNR:
$ watch -n1 'iw dev wlan0 link | grep -E "signal|bitrate"'
# signal: -52 dBm tx bitrate: 780.0 MBit/s MCS 9
# signal: -68 dBm tx bitrate: 292.5 MBit/s MCS 5 <- SNR dropped, MCS fell
# signal: -78 dBm tx bitrate: 58.5 MBit/s MCS 2
# Measure the real throughput a given SNR supports:
$ iperf3 -c 192.168.1.1 -t 10 | tail -3
# [ 5] 0.00-10.00 sec 512 MBytes 429 Mbits/sec sender
# Compare with the Shannon bound for your channel width and SNR:
$ python3 -c "
import math
bw, snr = 80e6, 48
print(f'Shannon bound: {bw*math.log2(1+10**(snr/10))/1e6:.0f} Mb/s')"
# Shannon bound: 1276 Mb/s
# Real throughput is well below the bound: overhead, contention, and
# practical coding all take their share.
# Per-frame error statistics:
$ cat /sys/class/net/wlan0/statistics/rx_crc_errors
# 1204
$ cat /sys/class/net/wlan0/statistics/rx_packets
# 894213
$ python3 -c "print(f'frame error rate: {1204/894213:.4%}')"
# frame error rate: 0.1346%
Key takeaways
- SNR, not absolute signal strength, determines link quality — a −80 dBm signal in a quiet band beats −55 dBm in a noisy one.
- The thermal noise floor is −174 dBm/Hz, giving about −101 dBm for a 20 MHz channel; real receivers add a 5–10 dB noise figure.
- Wider channels collect proportionally more noise (+3 dB per doubling), so a 160 MHz channel needs 9 dB more signal than 20 MHz for equal SNR.
- BER falls off a cliff: roughly 5 dB separates "unusable" from "effectively perfect", which is why wireless quality is not gradual.
- Frames amplify bit errors — a BER of 10⁻⁴ loses about 70% of 1500-byte frames.
- Higher-order modulations need more SNR because constellation points sit closer together: BPSK needs ~2 dB, 1024-QAM needs ~37 dB.
- Shannon: bandwidth buys capacity linearly, SNR only logarithmically — which is why every generation reaches for wider channels and more spectrum rather than more power.
🧪 Practice
- Compute the thermal noise floor for a 40 MHz channel with a 6 dB noise figure, and the SNR for a −65 dBm signal against it.
- Explain why a device reporting −55 dBm can perform worse than one reporting −75 dBm.
- Using Shannon, compare the capacity gain from doubling channel width against gaining 10 dB of SNR, and say which is easier to obtain in practice.
- Interview-style: A link shows strong signal (−50 dBm) but 20% of frames need retransmission. What is wrong? (Hint: signal strength says nothing about what else is transmitting on the same frequency — which term in the SINR denominator have you not measured?)
Interference and Spectrum Regulation
Theory
A cable is private; the air is not. Every wireless device shares the spectrum with every other device using the same frequencies, and unlike collisions on a switched Ethernet, nobody is in charge. Understanding wireless performance in practice means understanding who else is transmitting and under what rules.
Spectrum is divided into two broad regimes:
Licensed spectrum is exclusively assigned by a national regulator, usually through auction, and the licensee has legal protection from interference. Cellular operators pay billions for it, and in exchange they can plan their networks knowing exactly what else is transmitting: nothing. This is why cellular quality-of-service guarantees are meaningful and Wi-Fi's are not.
Unlicensed spectrum — the ISM bands at 2.4 GHz and 5.8 GHz, plus 5 GHz U-NII and 6 GHz — is free to use under rules on power, bandwidth, and interference tolerance. The foundational condition is that devices must accept interference from others. Wi-Fi, Bluetooth, Zigbee, cordless phones, and microwave ovens all coexist there by mutual tolerance rather than by coordination.
The 2.4 GHz band's problems are worth stating concretely because they explain most real-world Wi-Fi complaints:
- It is only ~83 MHz wide, with 20 MHz channels, so there are just three non-overlapping channels (1, 6, 11 in most regions).
- Microwave ovens emit around 2.45 GHz at enormous power, periodically wiping out channels 6–11.
- Bluetooth hops across the whole band 1,600 times per second.
- Signals penetrate walls well, which is good for range and bad for interference — your neighbours' networks reach you too.
The 5 GHz band is far better: roughly 500 MHz of spectrum, around 25 non-overlapping 20 MHz channels, and shorter range that conveniently limits inter-network interference. Its complication is DFS (Dynamic Frequency Selection): many 5 GHz channels are shared with weather and military radar, so devices must listen for radar and vacate the channel within 10 seconds if detected. This produces the confusing symptom of an AP spontaneously changing channel and dropping clients.
The 6 GHz band (Wi-Fi 6E and 7) adds 1200 MHz — more than doubling total Wi-Fi spectrum in one step — and is restricted to modern devices, so it is currently free of legacy congestion.
Two regulatory mechanisms shape real deployments. EIRP limits cap effective radiated power, which combines transmit power and antenna gain: a high-gain antenna is not a way to exceed the limit, it is a way to trade coverage shape for reach within it. And regulatory domains differ by country — channel availability, power limits, and DFS rules all vary, which is why devices carry a country code and why a channel that works in one region may be illegal in another.
Examples
2.4 GHz: why only three channels
ch: 1 2 3 4 5 6 7 8 9 10 11 12 13
MHz:2412 2417 2422 2427 2432 2437 2442 2447 2452 2457 2462 2467 2472
|----22 MHz wide----|
\_______ch 1_______/
\_______ch 6_______/
\______ch 11_______/
^^^^^^^^^^^^^^^^^^^^ NON-OVERLAPPING: 1, 6, 11 only
Using channels 1, 3, 5 means all three OVERLAP and corrupt each other.
Three overlapping APs perform WORSE than two non-overlapping ones.
WHAT ELSE LIVES AT 2.4 GHz
2400 ------------------------------------------------ 2483 MHz
| Wi-Fi ch 1 ch 6 ch 11 |
| [=========] [=========] [=========] |
| |
| Bluetooth: hops across the ENTIRE band, 1600 hops/s |
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| |
| Microwave oven: ~2450 MHz, HUGE power, 50% duty cycle|
| [#################] |
| |
| Zigbee ch 11-26, cordless phones, USB 3 noise, ... |
All of these must TOLERATE each other. That is the price of "free".
LICENSED vs UNLICENSED
LICENSED (cellular) UNLICENSED (Wi-Fi)
+-----------------------+ +------------------------+
| exclusive assignment | | anyone may transmit |
| legal protection | | MUST ACCEPT interference|
| billions at auction | | free |
| QoS guarantees POSSIBLE| | QoS is best effort |
| operator plans it all | | nobody is in charge |
+-----------------------+ +------------------------+
predictable unpredictable
DFS: sharing 5 GHz with radar
AP transmitting on ch 100 (5500 MHz, a DFS channel)
|
| radar pulse detected
v
AP MUST: stop transmitting within 200 ms
vacate the channel within 10 s
not return for 30 minutes
|
v
clients are disconnected and must re-associate elsewhere
^ this is why an AP "randomly" changes channel and drops everyone
# --- 2.4 GHz channel overlap ---
def channel_center_mhz(ch):
return 2407 + ch * 5 if 1 <= ch <= 13 else 2484
def overlaps(ch_a, ch_b, width_mhz=22):
a, b = channel_center_mhz(ch_a), channel_center_mhz(ch_b)
return abs(a - b) < width_mhz
print("2.4 GHz overlap matrix (X = interferes):")
chans = [1, 3, 5, 6, 8, 11, 13]
print(" " + "".join(f"{c:>4}" for c in chans))
for a in chans:
row = "".join(f"{'X' if a != b and overlaps(a, b) else '.':>4}" for b in chans)
print(f"ch{a:>3}{row}")
# 1 3 5 6 8 11 13
# ch 1 . X X . . . .
# ch 3 X . X X . . .
# ch 5 X X . X X . .
# ch 6 . X X . X . .
# ch 8 . . X X . X .
# ch 11 . . . . X . X
# ch 13 . . . . . X .
# Channels 1, 6, 11 are mutually clear. Everything else collides.
def find_non_overlapping():
chosen = []
for ch in range(1, 14):
if all(not overlaps(ch, c) for c in chosen):
chosen.append(ch)
return chosen
print(f"\nnon-overlapping set: {find_non_overlapping()}")
# non-overlapping set: [1, 6, 11]
# --- Spectrum available per band ---
BANDS = {
"2.4 GHz ISM": {"mhz": 83, "ch20": 3, "dfs": False, "walls": "good"},
"5 GHz U-NII": {"mhz": 500, "ch20": 25, "dfs": True, "walls": "fair"},
"6 GHz": {"mhz": 1200, "ch20": 59, "dfs": False, "walls": "poor"},
"60 GHz": {"mhz": 14000,"ch20": 6, "dfs": False, "walls": "none"},
}
print(f"\n{'band':<16}{'spectrum':>10}{'20MHz ch':>10}{'DFS':>6}{'wall penetration':>19}")
for name, b in BANDS.items():
print(f"{name:<16}{b['mhz']:>7} MHz{b['ch20']:>10}"
f"{('yes' if b['dfs'] else 'no'):>6}{b['walls']:>19}")
# band spectrum 20MHz ch DFS wall penetration
# 2.4 GHz ISM 83 MHz 3 no good
# 5 GHz U-NII 500 MHz 25 yes fair
# 6 GHz 1200 MHz 59 no poor
# 60 GHz 14000 MHz 6 no none
# 6 GHz more than DOUBLED total Wi-Fi spectrum in a single step.
# How many wide channels fit in each band:
def channels_at_width(band_mhz, width_mhz):
return band_mhz // width_mhz
print(f"\n{'band':<14}{'20MHz':>8}{'40MHz':>8}{'80MHz':>8}{'160MHz':>9}{'320MHz':>9}")
for name, b in BANDS.items():
if b["mhz"] > 5000: continue
row = "".join(f"{channels_at_width(b['mhz'], w):>8}" for w in (20, 40, 80))
row += f"{channels_at_width(b['mhz'],160):>9}{channels_at_width(b['mhz'],320):>9}"
print(f"{name:<14}{row}")
# band 20MHz 40MHz 80MHz 160MHz 320MHz
# 2.4 GHz ISM 4 2 1 0 0
# 5 GHz U-NII 25 12 6 3 1
# 6 GHz 60 30 15 7 3
# 160 MHz channels are essentially impossible below 6 GHz in a dense area.
# --- EIRP: power and antenna gain are one budget ---
def eirp_dbm(tx_power_dbm, antenna_gain_dbi, cable_loss_db=0):
return tx_power_dbm + antenna_gain_dbi - cable_loss_db
LIMITS = {"2.4 GHz (EU)": 20, "2.4 GHz (US)": 36, "5 GHz U-NII-1 (EU)": 23,
"5 GHz U-NII-3 (US)": 36, "6 GHz LPI (indoor)": 30}
print(f"\n{'configuration':<34}{'EIRP':>9} compliant in")
configs = [("20 dBm radio, 3 dBi omni", 20, 3), ("20 dBm radio, 9 dBi panel", 20, 9),
("23 dBm radio, 17 dBi directional", 23, 17), ("14 dBm radio, 6 dBi", 14, 6)]
for name, p, g in configs:
e = eirp_dbm(p, g)
ok = [k for k, v in LIMITS.items() if e <= v]
print(f"{name:<34}{e:>6} dBm {', '.join(ok) if ok else 'NONE -- illegal'}")
# configuration EIRP compliant in
# 20 dBm radio, 3 dBi omni 23 dBm 2.4 GHz (US), 5 GHz U-NII-1 (EU), ...
# 20 dBm radio, 9 dBi panel 29 dBm 2.4 GHz (US), 5 GHz U-NII-3 (US), 6 GHz LPI
# 23 dBm radio, 17 dBi directional 40 dBm NONE -- illegal
# 14 dBm radio, 6 dBi 20 dBm all
# A high-gain antenna does not let you exceed the limit -- it makes you
# reduce transmit power to stay within it, trading coverage SHAPE for reach.
# --- The airtime cost of interference ---
def effective_throughput(nominal_mbps, competing_networks, utilisation=0.6):
"""Co-channel networks SHARE airtime; they do not add capacity."""
share = 1 / (1 + competing_networks)
return nominal_mbps * share * utilisation
print(f"\n{'co-channel APs':>16}{'your share':>13}{'effective (of 400 Mb/s)':>26}")
for n in (0, 1, 3, 7, 15):
print(f"{n:>16}{1/(1+n):>12.1%}{effective_throughput(400, n):>22.0f} Mb/s")
# co-channel APs your share effective (of 400 Mb/s)
# 0 100.0% 240 Mb/s
# 1 50.0% 120 Mb/s
# 3 25.0% 60 Mb/s
# 7 12.5% 30 Mb/s
# 15 6.2% 15 Mb/s
# In an apartment block, spectrum is divided by the number of neighbours.
# No upgrade to your own AP changes this.
# --- DFS channels and their operational cost ---
DFS_CHANNELS = list(range(52, 65, 4)) + list(range(100, 145, 4))
NON_DFS = [36, 40, 44, 48, 149, 153, 157, 161, 165]
print(f"\nnon-DFS 5 GHz channels : {NON_DFS} ({len(NON_DFS)} available)")
print(f"DFS 5 GHz channels : {len(DFS_CHANNELS)} more, but:")
for rule in ("must monitor for radar continuously",
"stop transmitting within 200 ms of detection",
"vacate the channel within 10 seconds",
"may not return for 30 minutes",
"60 s channel availability check BEFORE first use"):
print(f" - {rule}")
# non-DFS 5 GHz channels : [36, 40, 44, 48, 149, 153, 157, 161, 165] (9 available)
# DFS 5 GHz channels : 15 more, but:
# - must monitor for radar continuously
# - stop transmitting within 200 ms of detection
# - ...
# DFS doubles your channel count and introduces spontaneous disconnections.
# What the regulatory domain permits here:
$ iw reg get
# country GB: DFS-ETSI
# (2402 - 2482 @ 40), (N/A, 20), (N/A)
# (5170 - 5250 @ 80), (N/A, 23), (N/A), AUTO-BW
# (5250 - 5330 @ 80), (N/A, 20), (0 ms), DFS, AUTO-BW
# ^^^^^^ ^^^ DFS required here
# (5490 - 5710 @ 160), (N/A, 27), (0 ms), DFS
# (5925 - 6425 @ 320), (N/A, 23), (N/A), NO-OUTDOOR
# ^^^ 6 GHz: 320 MHz channels, indoor only
# List channels and flags for the interface:
$ iw phy phy0 info | grep -A2 '5180 MHz'
# * 5180 MHz [36] (23.0 dBm)
# * 5200 MHz [40] (23.0 dBm)
# * 5260 MHz [52] (20.0 dBm) (radar detection)
# ^^^^^^^^^^^^^^^ a DFS channel
# Survey the band: who else is using the air?
$ sudo iw dev wlan0 scan | grep -E 'SSID|freq|signal' | paste - - - | \
awk '{print $NF, $2, $4}' | sort | uniq -c | sort -rn | head
# SSID freq signal
# neighbour-1 2437 -68
# neighbour-2 2437 -71
# cafe-guest 2437 -79 <- three networks all on channel 6
# Count APs per channel to plan around them:
$ sudo iw dev wlan0 scan | awk '/freq:/{print $2}' | sort -n | uniq -c | sort -rn
# 11 2437 <- channel 6 is saturated
# 8 2412 <- channel 1
# 6 2462 <- channel 11
# 2 5180 <- 5 GHz nearly empty
# 1 5745
# Channel utilisation: how much airtime is already consumed?
$ sudo iw dev wlan0 survey dump | grep -A5 '2437 MHz'
# frequency: 2437 MHz
# channel active time: 10000 ms
# channel busy time: 7821 ms
# ^^^^ 78% BUSY before you transmit anything
$ python3 -c "print(f'airtime available: {1-7821/10000:.0%}')"
# airtime available: 22%
# Detect a DFS radar event in the logs:
$ journalctl -u hostapd | grep -i radar
# hostapd: DFS-RADAR-DETECTED freq=5580 ht_enabled=1 chan_width=2
# hostapd: DFS-NEW-CHANNEL freq=5180 chan=36 sec_chan=1
# The AP vacated within 10 s, as required, and every client re-associated.
# Microwave oven interference has a distinctive signature:
$ sudo iw dev wlan0 survey dump | grep -A3 '2450 MHz'
# channel busy time: 4900 ms of 10000 ms
# ~50% duty cycle centred near 2450 MHz, appearing and vanishing in
# two-minute bursts, is almost always a microwave.
# Verify EIRP compliance for your configuration:
$ iw dev wlan0 info | grep txpower
# txpower 20.00 dBm
# Add antenna gain to get EIRP, then compare against `iw reg get`.
Key takeaways
- Licensed spectrum is exclusive and legally protected, enabling real QoS guarantees; unlicensed spectrum is free but devices must accept interference.
- 2.4 GHz has only three non-overlapping channels (1, 6, 11) in ~83 MHz, shared with Bluetooth, Zigbee, cordless phones, and microwave ovens.
- 5 GHz offers ~25 channels but many require DFS — vacating within 10 seconds of radar detection, which causes spontaneous AP channel changes and client drops.
- 6 GHz adds 1200 MHz, more than doubling Wi-Fi spectrum and remaining free of legacy devices.
- EIRP limits combine transmit power and antenna gain: a high-gain antenna trades coverage shape for reach, it does not raise the ceiling.
- Regulatory domains differ by country — channels, power, and DFS rules all vary, which is why devices carry a country code.
- Co-channel networks share airtime rather than adding capacity: in a dense area your throughput is divided by the number of neighbours, and no upgrade to your own AP changes that.
🧪 Practice
- Explain why deploying three APs on channels 1, 3, and 5 performs worse than two APs on channels 1 and 11.
- An AP with a 20 dBm radio uses a 12 dBi antenna. Compute EIRP and state whether it is legal under a 20 dBm and a 36 dBm limit.
- Describe the sequence of events when a DFS channel detects radar, with the required timings.
- Interview-style: A customer upgrades to the newest Wi-Fi standard and sees no improvement in a crowded apartment block. (Hint: their bottleneck is not their hardware — ask what fraction of the airtime was available before they upgraded.)
Adaptive Modulation and Coding
Theory
The previous two topics establish a tension. Signal quality varies continuously — with distance, obstacles, and centimetre-scale multipath — while a radio must pick one modulation and one coding rate for each transmission. Choose too aggressively and frames fail; choose too conservatively and you waste capacity that was available.
Adaptive modulation and coding (AMC) resolves this by changing the transmission scheme per frame, per link, based on continuously measured channel quality. It is why a phone shows "full bars" at 5 Mb/s in one place and 800 Mb/s in another, with no configuration change.
Two independent parameters are adapted:
Modulation sets how many bits each symbol carries. BPSK carries 1 bit, QPSK 2, 16-QAM 4, 64-QAM 6, 256-QAM 8, and 1024-QAM 10. Higher order means more throughput and less noise tolerance, because constellation points sit closer together.
Coding rate sets how much redundancy is added for forward error correction. A rate of 1/2 means half the transmitted bits are parity; 5/6 means only one bit in six. Lower rates survive worse channels at the cost of throughput.
The combination is indexed as an MCS (Modulation and Coding Scheme), and the data rate follows directly:
rate = subcarriers × bits_per_symbol × coding_rate × spatial_streams / symbol_duration
The rate adaptation algorithm decides which MCS to use, and the design problem is harder than it looks because the channel changes faster than you can measure it. Three families exist:
- Throughput-based (Minstrel, used by Linux): periodically send a small fraction of frames at other rates to sample their success, and pick the MCS with the highest expected throughput — success probability times rate. Robust, because it measures what actually matters rather than inferring it.
- SNR-based: map measured SNR directly to an MCS from a table. Fast to react, but SNR measured at the receiver during the last frame may not predict the next one.
- Loss-based (ARF and descendants): step down after consecutive failures, step up after consecutive successes. Simple, but conflates collision loss with channel loss — a busy channel looks like a weak one, so the radio slows down when it should not.
That last failure mode matters. In a congested network, frames are lost to collisions, not to poor signal. A loss-based algorithm responds by lowering the rate, which makes each frame occupy the air longer, which causes more collisions. Modern algorithms therefore distinguish the two, typically by checking whether the frame was even acknowledged versus corrupted.
There is also a subtle airtime effect worth internalising: slow clients hurt everyone. Because the medium is shared, a device transmitting at 6 Mb/s occupies the channel roughly 100 times longer per byte than one at 600 Mb/s. One distant client can consume most of a cell's airtime — the "performance anomaly" of 802.11, and the reason enterprise deployments often disable the lowest rates entirely.
Examples
One link, many rates: adaptation across a room
distance SNR MCS modulation rate airtime for 1 MB
---------------------------------------------------------------------
2 m 42 dB MCS11 1024-QAM 5/6 143 Mb/s 56 ms
8 m 33 dB MCS9 256-QAM 5/6 87 Mb/s 92 ms
15 m 24 dB MCS7 64-QAM 5/6 65 Mb/s 123 ms
25 m 16 dB MCS4 16-QAM 3/4 39 Mb/s 205 ms
40 m 8 dB MCS1 QPSK 1/2 13 Mb/s 615 ms
55 m 3 dB MCS0 BPSK 1/2 6.5Mb/s 1,231 ms
Range does not END. The RATE degrades -- and airtime cost explodes.
THE 802.11 PERFORMANCE ANOMALY
cell with 4 fast clients (100 Mb/s each):
each gets 1/4 of the airtime -> 25 Mb/s each
add ONE slow client at 6 Mb/s, all sending equal DATA:
the slow client needs ~17x more AIRTIME per byte
airtime split: fast: fast: fast: fast: SLOW
[#] [#] [#] [#] [#################]
-> the fast clients now get a fraction of what they had
One distant device degrades the whole cell.
This is why enterprises disable 1/2/5.5/6 Mb/s basic rates.
RATE ADAPTATION: the decision, every few frames
measure channel
|
+--------+--------+
| |
frames succeeding frames failing
| |
try a HIGHER MCS was it a COLLISION or CORRUPTION?
| / \
still good? collision corruption
keep it (channel busy) (weak signal)
else revert DO NOT slow down STEP DOWN
-> retry instead
Conflating collisions with weak signal is the classic bug:
slowing down makes frames longer, causing MORE collisions.
import math, random
# --- Rate computation from MCS parameters ---
def data_rate_mbps(subcarriers, bits_per_symbol, coding_rate,
spatial_streams, symbol_us):
"""The formula behind every number in an 802.11 rate table."""
bits_per_ofdm_symbol = subcarriers * bits_per_symbol * coding_rate * spatial_streams
return bits_per_ofdm_symbol / symbol_us
MODULATIONS = {"BPSK": 1, "QPSK": 2, "16-QAM": 4, "64-QAM": 6,
"256-QAM": 8, "1024-QAM": 10}
# 802.11ac 80 MHz: 234 data subcarriers, 3.6 us symbol (short GI)
print(f"{'MCS':<6}{'modulation':<11}{'code':<7}{'1 stream':>11}{'2 streams':>12}{'min SNR':>10}")
TABLE = [(0,"BPSK",1/2,2), (1,"QPSK",1/2,5), (2,"QPSK",3/4,9),
(3,"16-QAM",1/2,11), (4,"16-QAM",3/4,15), (5,"64-QAM",2/3,18),
(6,"64-QAM",3/4,20), (7,"64-QAM",5/6,25), (8,"256-QAM",3/4,29),
(9,"256-QAM",5/6,31), (11,"1024-QAM",5/6,37)]
for mcs, mod, code, snr in TABLE:
r1 = data_rate_mbps(234, MODULATIONS[mod], code, 1, 3.6)
r2 = data_rate_mbps(234, MODULATIONS[mod], code, 2, 3.6)
print(f"{mcs:<6}{mod:<11}{f'{code:.2f}':<7}{r1:>8.1f} Mb{r2:>9.1f} Mb{snr:>9} dB")
# MCS modulation code 1 stream 2 streams min SNR
# 0 BPSK 0.50 32.5 Mb 65.0 Mb 2 dB
# 1 QPSK 0.50 65.0 Mb 130.0 Mb 5 dB
# ...
# 11 1024-QAM 0.83 541.7 Mb 1083.3 Mb 37 dB
# Every term in the formula is a lever: subcarriers (channel width),
# bits/symbol (modulation), coding rate, and spatial streams (MIMO).
# --- Expected throughput: the metric Minstrel actually optimises ---
def success_probability(snr_db, required_snr_db, steepness=0.8):
"""Logistic approximation of the BER cliff."""
return 1 / (1 + math.exp(-steepness * (snr_db - required_snr_db - 2)))
def expected_throughput(snr_db, mcs_entry):
mcs, mod, code, req = mcs_entry
rate = data_rate_mbps(234, MODULATIONS[mod], code, 2, 3.6)
p = success_probability(snr_db, req)
return rate * p, rate, p
def best_mcs(snr_db):
scored = [(expected_throughput(snr_db, e)[0], e) for e in TABLE]
return max(scored, key=lambda x: x[0])
print(f"\n{'SNR':>6}{'best MCS':>10}{'raw rate':>12}{'success':>10}{'effective':>12}")
for snr in (4, 10, 18, 26, 33, 40):
tput, entry = best_mcs(snr)
_, rate, p = expected_throughput(snr, entry)
print(f"{snr:>4} dB{f'MCS{entry[0]}':>10}{rate:>9.0f} Mb{p:>9.0%}{tput:>9.0f} Mb")
# SNR best MCS raw rate success effective
# 4 dB MCS0 65 Mb 85% 55 Mb
# 10 dB MCS2 195 Mb 92% 179 Mb
# 18 dB MCS5 260 Mb 88% 229 Mb
# 26 dB MCS7 433 Mb 89% 385 Mb
# 33 dB MCS9 650 Mb 92% 599 Mb
# 40 dB MCS11 1083 Mb 98% 1058 Mb
# Note: the highest RATE is not always the best choice. A 1083 Mb/s rate
# at 30% success delivers less than a 650 Mb/s rate at 92%.
# Proof that greedy rate selection is wrong:
snr = 30
print(f"\nAt {snr} dB SNR, comparing candidate rates:")
for entry in TABLE[-4:]:
tput, rate, p = expected_throughput(snr, entry)
marker = " <- best" if entry == best_mcs(snr)[1] else ""
print(f" MCS{entry[0]:<3}{rate:>8.0f} Mb/s x {p:>5.0%} success = "
f"{tput:>7.0f} Mb/s effective{marker}")
# At 30 dB SNR, comparing candidate rates:
# MCS7 433 Mb/s x 97% success = 419 Mb/s effective
# MCS8 585 Mb/s x 67% success = 390 Mb/s effective
# MCS9 650 Mb/s x 50% success = 325 Mb/s effective
# MCS11 1083 Mb/s x 1% success = 13 Mb/s effective
# MCS7 beats MCS11 by 30x. Picking the fastest rate you can "reach" is wrong.
# --- The performance anomaly: slow clients steal airtime ---
def airtime_ms(bytes_to_send, rate_mbps):
return bytes_to_send * 8 / (rate_mbps * 1e6) * 1000
print(f"\nairtime to send 1 MB:")
for rate in (6.5, 39, 87, 143, 600):
print(f" {rate:>6.1f} Mb/s -> {airtime_ms(1e6, rate):>8.1f} ms")
# 6.5 Mb/s -> 1230.8 ms
# 39.0 Mb/s -> 205.1 ms
# 87.0 Mb/s -> 92.0 ms
# 143.0 Mb/s -> 55.9 ms
# 600.0 Mb/s -> 13.3 ms
# The slowest client needs 92x the airtime of the fastest for the same data.
def cell_throughput(client_rates_mbps, bytes_each=1e6):
"""Equal DATA per client -> airtime is dominated by the slowest."""
times = [airtime_ms(bytes_each, r) for r in client_rates_mbps]
total_ms = sum(times)
total_bytes = bytes_each * len(client_rates_mbps)
return total_bytes * 8 / (total_ms / 1000) / 1e6, times
fast_only = [600, 600, 600, 600]
with_slow = [600, 600, 600, 600, 6.5]
t1, _ = cell_throughput(fast_only)
t2, times = cell_throughput(with_slow)
print(f"\n4 fast clients : {t1:>7.0f} Mb/s aggregate")
print(f"4 fast + 1 slow (6.5 Mb/s): {t2:>7.0f} Mb/s aggregate "
f"({(1-t2/t1):.0%} LOSS)")
print(f" the slow client uses {times[-1]/sum(times):.0%} of all airtime")
# 4 fast clients : 600 Mb/s aggregate
# 4 fast + 1 slow (6.5 Mb/s): 41 Mb/s aggregate (93% LOSS)
# the slow client uses 96% of all airtime
# ONE distant device destroyed the cell. Hence: disable low basic rates.
# --- Why loss-based adaptation misbehaves under congestion ---
def naive_adapt(loss_rate, current_mcs, cause):
"""ARF-style: any loss looks like a weak signal."""
if loss_rate > 0.2:
return max(0, current_mcs - 1), "stepped DOWN"
return current_mcs, "held"
print(f"\n{'cause of loss':<24}{'naive action':<16}{'consequence'}")
for cause in ("weak signal", "collision (congestion)"):
new, action = naive_adapt(0.35, 7, cause)
effect = ("correct: stronger coding survives the noise"
if cause == "weak signal" else
"WRONG: longer frames -> MORE collisions -> more loss")
print(f"{cause:<24}{action:<16}{effect}")
# cause of loss naive action consequence
# weak signal stepped DOWN correct: stronger coding survives the noise
# collision (congestion) stepped DOWN WRONG: longer frames -> MORE collisions -> more loss
# A congestion collapse spiral. Modern algorithms check whether the frame
# was CORRUPTED (signal) or simply never ACKed amid a busy channel (collision).
# Watch the rate adapt in real time as you walk around:
$ watch -n1 'iw dev wlan0 link | grep -E "signal|bitrate"'
# signal: -41 dBm
# tx bitrate: 1201.0 MBit/s 80MHz HE-MCS 11 HE-NSS 2 HE-GI 0
# ^^^^^^^^^ chosen by rate adaptation
# signal: -67 dBm
# tx bitrate: 344.1 MBit/s 80MHz HE-MCS 5 HE-NSS 2
# Linux uses Minstrel -- inspect its per-rate statistics directly:
$ sudo cat /sys/kernel/debug/ieee80211/phy0/netdev:wlan0/stations/*/rc_stats
# type rate throughput ewma prob this prob this succ/attempt success attempts
# HE MCS5 210.4 94.2 100.0 12( 12) 8412 8901
# HE MCS7 A 298.1 88.1 90.0 18( 20) 4102 4655
# HE MCS9 241.0 52.3 50.0 4( 8) 891 1702
# ^ 'A' marks the CURRENT choice: highest THROUGHPUT, not highest rate
# Note MCS9 has a higher raw rate than MCS7 but lower throughput at 52% success.
# Sampling: Minstrel deliberately probes other rates to keep its table fresh:
$ sudo cat /sys/kernel/debug/ieee80211/phy0/netdev:wlan0/stations/*/rc_stats_csv | head -3
# HE,MCS5,,210.4,94.2,...
# HE,MCS7,A,298.1,88.1,...
# ~10% of frames are sent at non-optimal rates purely to measure them.
# Retry statistics reveal whether adaptation is struggling:
$ iw dev wlan0 station dump | grep -E 'signal|tx bitrate|tx retries|tx failed'
# signal: -58 dBm
# tx bitrate: 866.7 MBit/s
# tx retries: 42180
# tx failed: 1204
# High retries with GOOD signal = collisions, not weak signal.
# Find slow clients dragging down a cell (the performance anomaly):
$ iw dev wlan0 station dump | awk '
> /Station/{mac=$2}
> /tx bitrate/{print mac, $3, $4}' | sort -k2 -n | head -3
# a4:83:e7:1f:2a:9b 6.0 MBit/s <- this one client may own most of the airtime
# 3c:37:86:44:02:c1 58.5 MBit/s
# b8:27:eb:91:04:7e 866.7 MBit/s
# Disable low basic rates so distant clients must roam rather than crawl
# (hostapd):
$ grep -E 'basic_rate|supported_rates' /etc/hostapd/hostapd.conf
# basic_rates=120 180 240
# ^^^ 12, 18, 24 Mb/s: 1/2/5.5/11 Mb/s are refused entirely
# Clients too far for 12 Mb/s are pushed to roam to a nearer AP.
# Airtime fairness (an alternative fix) schedules by TIME, not by frames:
$ iw phy phy0 info | grep -i 'airtime'
# * Airtime fairness: supported
# Each client gets equal AIRTIME rather than equal frame opportunities,
# so a slow client can no longer monopolise the medium.
Key takeaways
- AMC changes modulation and coding per frame based on measured channel quality — the reason one device shows wildly different rates in different places.
- Modulation sets bits per symbol (BPSK 1 to 1024-QAM 10); coding rate sets redundancy (1/2 survives more than 5/6).
- Rate is
subcarriers × bits/symbol × coding rate × streams / symbol time— every term is an independent lever. - The right objective is expected throughput (rate × success probability), not the highest reachable rate: a fast rate at 30% success loses to a slower one at 92%.
- Minstrel samples alternative rates continuously to keep its estimates fresh; SNR-based selection reacts faster but predicts poorly.
- Loss-based adaptation confuses collisions with weak signal, slowing down under congestion and causing more collisions.
- The 802.11 performance anomaly: a slow client consumes airtime proportional to its slowness, so one distant device can degrade the whole cell — fixed by disabling low basic rates or airtime fairness.
🧪 Practice
- Compute the data rate for 234 subcarriers, 64-QAM, rate 3/4, two spatial streams, and a 3.6 µs symbol.
- At 30 dB SNR, MCS7 succeeds 97% of the time at 433 Mb/s and MCS11 succeeds 1% at 1083 Mb/s. Show which is better and by how much.
- Explain why one 6 Mb/s client can reduce a cell's aggregate throughput by over 90%, and give two mitigations.
- Interview-style: A rate-adaptation algorithm makes a congested network worse rather than better. Explain the feedback loop. (Hint: ask what the algorithm assumes a lost frame means, and what lowering the rate does to how long each frame occupies the air.)
MIMO and Beamforming
Theory
Shannon's theorem says capacity grows only logarithmically with SNR, so throwing power at the problem yields diminishing returns. MIMO (Multiple Input, Multiple Output) escapes that limit by adding a dimension the formula does not cover: space.
The insight is counterintuitive. Multipath was presented earlier as a problem — copies of a signal arriving via different paths, interfering destructively. MIMO treats those distinct paths as independent channels. With multiple antennas at each end, the receiver can solve a system of linear equations to separate signals that were transmitted simultaneously on the same frequency.
The capacity becomes:
C ≈ min(N_tx, N_rx) × B × log₂(1 + SNR)
The number of spatial streams is bounded by the smaller antenna count, and capacity scales linearly with it. A 4×4 MIMO link carries roughly four times the data of a 1×1 link in the same spectrum with the same power. This is why Wi-Fi throughput has grown far faster than either bandwidth or SNR alone would allow.
MIMO is used in three distinct ways, and conflating them is a common confusion:
Spatial multiplexing sends different data on each stream, multiplying throughput. It requires a rich multipath environment — paradoxically, MIMO works better indoors with reflections than in an open field with a clean line of sight, because distinct paths are what make the equations solvable.
Diversity sends the same data over multiple antennas so that if one path is in a fade, another is not. It improves reliability rather than throughput, and it is what helps at the edge of coverage.
Beamforming adjusts the phase of each antenna's transmission so the copies add constructively at the intended receiver and cancel elsewhere. The array behaves like a directional antenna that can be steered electronically, with no moving parts. Explicit beamforming (802.11ac and later) requires the receiver to send back a channel measurement — a sounding exchange — so the transmitter knows which phases to use.
MU-MIMO (Multi-User MIMO) is the extension that matters most for dense deployments: rather than using all streams for one client, the AP transmits to several clients simultaneously on the same frequency, separated spatially by beamforming. 802.11ac added downlink MU-MIMO; 802.11ax added uplink. This converts MIMO from a per-link speed feature into a cell capacity feature, which is the more valuable property when many devices share an AP.
The practical caveats are worth stating. The number of streams is limited by the client, not the AP — a phone with two antennas gets two streams from an eight-antenna AP. Antennas must be separated by at least half a wavelength to see sufficiently independent paths, which is why laptops manage more streams than phones. And each additional radio chain costs power, which is why battery-powered devices deliberately use fewer.
Examples
SISO vs MIMO: space as a new dimension
SISO (1x1) MIMO (2x2) spatial multiplexing
[TX]-------->[RX] [TX1]---\ /--->[RX1]
\/
one stream [TX2]---/\--->[RX2]
C = B log2(1+SNR)
TWO streams, SAME frequency, SAME time
C = 2 x B log2(1+SNR)
The receiver solves:
y1 = h11*x1 + h12*x2 + n1
y2 = h21*x1 + h22*x2 + n2
Two equations, two unknowns -> recover x1 and x2 separately.
This works ONLY if the channel matrix H is well-conditioned, which
requires DISTINCT PATHS -- i.e. rich multipath. MIMO likes reflections.
THE THREE USES OF MULTIPLE ANTENNAS
SPATIAL MULTIPLEXING DIVERSITY BEAMFORMING
different data per stream same data per antenna phase-aligned copies
[A]--\ /--> [A]--\ [A]-\
\ / \ \___ constructive
[B]---/ \--> [B]----+--> pick best [B]-/ at the RX
-> 2x THROUGHPUT -> RELIABILITY -> more SNR, less
needs good SNR + multipath helps at cell edge interference elsewhere
BEAMFORMING: steering without moving
WITHOUT beamforming WITH beamforming
\ | / |
-- AP -- energy AP energy concentrated
/ | \ everywhere /||\ toward the client
/ || \
[client] gets a little [client] gets much more
[others] get interference [others] get less interference
Phases are chosen so the copies ARRIVE IN PHASE at the target.
No moving parts: the array is steered electronically, per frame.
MU-MIMO: one transmission, several clients
SU-MIMO (802.11n) MU-MIMO (802.11ac/ax)
time --> time -->
[--client A--][--client B--] [-- A and B SIMULTANEOUSLY --]
[--client C--] [-- C and D SIMULTANEOUSLY --]
sequential, one at a time spatially separated, concurrent
-> MIMO improves ONE LINK -> MIMO improves CELL CAPACITY
import math, random
# --- MIMO capacity: linear in the number of streams ---
def mimo_capacity_mbps(bandwidth_hz, snr_db, n_tx, n_rx):
streams = min(n_tx, n_rx) # THE bound
snr_lin = 10 ** (snr_db / 10)
return streams * bandwidth_hz * math.log2(1 + snr_lin) / 1e6, streams
print(f"{'config':<10}{'streams':>9}{'capacity @25dB':>17}{'vs SISO':>10}")
base, _ = mimo_capacity_mbps(80e6, 25, 1, 1)
for tx, rx in ((1,1), (2,2), (3,3), (4,4), (8,8), (8,2), (2,8)):
cap, streams = mimo_capacity_mbps(80e6, 25, tx, rx)
print(f"{f'{tx}x{rx}':<10}{streams:>9}{cap:>14.0f} Mb{cap/base:>9.1f}x")
# config streams capacity @25dB vs SISO
# 1x1 1 664 Mb 1.0x
# 2x2 2 1329 Mb 2.0x
# 3x3 3 1993 Mb 3.0x
# 4x4 4 2658 Mb 4.0x
# 8x8 8 5315 Mb 8.0x
# 8x2 2 1329 Mb 2.0x <- CLIENT limits it
# 2x8 2 1329 Mb 2.0x
# An 8-antenna AP gives a 2-antenna phone exactly 2 streams.
# Capacity is LINEAR in streams -- unlike SNR, which is logarithmic.
# Compare the two ways to gain capacity:
snr_gain_needed = 10 * math.log10(2 ** (2 / 1) - 1)
print(f"\nto DOUBLE capacity at 25 dB SNR:")
print(f" via MIMO : add 1 spatial stream (1x1 -> 2x2)")
print(f" via SNR : need ~{mimo_capacity_mbps(80e6, 53, 1, 1)[0]/base:.1f}x "
f"at 53 dB, i.e. +28 dB = 630x the transmit power")
# to DOUBLE capacity at 25 dB SNR:
# via MIMO : add 1 spatial stream (1x1 -> 2x2)
# via SNR : need ~2.0x at 53 dB, i.e. +28 dB = 630x the transmit power
# Adding an antenna is vastly cheaper than adding 28 dB of power.
# --- Solving the MIMO channel: why multipath HELPS ---
def solve_2x2(H, y):
"""Recover the two transmitted symbols by inverting the channel matrix."""
det = H[0][0]*H[1][1] - H[0][1]*H[1][0]
if abs(det) < 1e-9:
return None, abs(det) # SINGULAR: cannot separate
x1 = ( H[1][1]*y[0] - H[0][1]*y[1]) / det
x2 = (-H[1][0]*y[0] + H[0][0]*y[1]) / det
return (x1, x2), abs(det)
scenarios = {
"rich multipath (indoor)": [[0.8, 0.6], [0.5, -0.9]],
"moderate multipath": [[0.9, 0.3], [0.4, 0.8]],
"line of sight, no scatter": [[0.9, 0.89], [0.88, 0.87]],
}
x_true = (1.0, -1.0)
print(f"\n{'environment':<30}{'|det H|':>10}{'recovered':>22}")
for name, H in scenarios.items():
y = [H[0][0]*x_true[0] + H[0][1]*x_true[1],
H[1][0]*x_true[0] + H[1][1]*x_true[1]]
x, det = solve_2x2(H, y)
rec = f"({x[0]:+.2f}, {x[1]:+.2f})" if x else "FAILED"
print(f"{name:<30}{det:>10.3f}{rec:>22}")
# environment |det H| recovered
# rich multipath (indoor) 1.020 (+1.00, -1.00)
# moderate multipath 0.600 (+1.00, -1.00)
# line of sight, no scatter 0.000 (+1.00, -1.00)
# |det H| near zero = the paths are nearly identical = the equations are
# nearly singular. With ANY noise, the solution explodes.
# MIMO needs SCATTERING. A clean line of sight is the WORST case for it.
# Noise amplification when the channel is poorly conditioned:
print(f"\nwith 1% measurement noise added to y:")
for name, H in scenarios.items():
y = [H[0][0]*x_true[0] + H[0][1]*x_true[1] + random.gauss(0, 0.01),
H[1][0]*x_true[0] + H[1][1]*x_true[1] + random.gauss(0, 0.01)]
x, det = solve_2x2(H, y)
err = max(abs(x[0]-x_true[0]), abs(x[1]-x_true[1])) if x else float('inf')
print(f" {name:<30} max error {err:>8.3f}")
# rich multipath (indoor) max error 0.017
# moderate multipath max error 0.029
# line of sight, no scatter max error 1.842 <- unusable
# Small noise, huge error: this is why LOS links use beamforming, not
# spatial multiplexing.
# --- Beamforming gain ---
def beamforming_gain_db(n_antennas):
"""Coherent combining: N antennas give 10*log10(N) dB of array gain."""
return 10 * math.log10(n_antennas)
print(f"\n{'antennas':>10}{'array gain':>13}{'range multiplier':>19}")
for n in (1, 2, 4, 8, 16, 64):
g = beamforming_gain_db(n)
# range scales as 10^(gain/(10*n_exponent)) with n=3 indoors
range_mult = 10 ** (g / 30)
print(f"{n:>10}{g:>10.1f} dB{range_mult:>18.2f}x")
# antennas array gain range multiplier
# 1 0.0 dB 1.00x
# 2 3.0 dB 1.26x
# 4 6.0 dB 1.58x
# 8 9.0 dB 2.00x
# 16 12.0 dB 2.51x
# 64 18.0 dB 3.98x
# Massive MIMO (64+ antennas) in 5G is largely about this array gain
# plus the ability to serve many users spatially.
# --- MU-MIMO: cell capacity, not link speed ---
def cell_throughput(ap_antennas, clients, client_antennas=2,
per_stream_mbps=300, mu_mimo=True):
if not mu_mimo:
# SU-MIMO: one client at a time, streams limited by that client
streams = min(ap_antennas, client_antennas)
return streams * per_stream_mbps
# MU-MIMO: total streams spread across clients simultaneously
total = min(ap_antennas, clients * client_antennas)
return total * per_stream_mbps
print(f"\nAP with 8 antennas, clients with 2 each, 300 Mb/s per stream:")
print(f"{'clients':>9}{'SU-MIMO':>12}{'MU-MIMO':>12}{'gain':>8}")
for c in (1, 2, 4, 8):
su = cell_throughput(8, c, mu_mimo=False)
mu = cell_throughput(8, c, mu_mimo=True)
print(f"{c:>9}{su:>9} Mb{mu:>9} Mb{mu/su:>7.1f}x")
# clients SU-MIMO MU-MIMO gain
# 1 600 Mb 600 Mb 1.0x
# 2 600 Mb 1200 Mb 2.0x
# 4 600 Mb 2400 Mb 4.0x
# 8 600 Mb 2400 Mb 4.0x
# SU-MIMO wastes 6 of the AP's 8 chains when serving a 2-antenna client.
# MU-MIMO uses them all, on different clients at once.
# --- Antenna spacing requirement ---
def min_spacing_cm(freq_ghz):
wavelength_cm = 30 / freq_ghz
return wavelength_cm / 2
print(f"\n{'band':<12}{'wavelength':>13}{'min spacing':>14} fits in")
for name, f in (("2.4 GHz", 2.4), ("5 GHz", 5.0), ("6 GHz", 6.0), ("28 GHz", 28.0)):
wl = 30 / f
print(f"{name:<12}{wl:>10.1f} cm{min_spacing_cm(f):>11.1f} cm "
f"{'phone' if min_spacing_cm(f) < 5 else 'laptop/AP only'}")
# band wavelength min spacing fits in
# 2.4 GHz 12.5 cm 6.2 cm laptop/AP only
# 5 GHz 6.0 cm 3.0 cm phone
# 6 GHz 5.0 cm 2.5 cm phone
# 28 GHz 1.1 cm 0.5 cm phone
# At 2.4 GHz, antennas must be 6 cm apart -- hard in a phone, easy in an AP.
# This is why higher frequencies enable more antennas in small devices,
# and why massive MIMO is a millimetre-wave technology.
# How many spatial streams does this link actually use?
$ iw dev wlan0 link | grep bitrate
# tx bitrate: 1201.0 MBit/s 80MHz HE-MCS 11 HE-NSS 2 HE-GI 0
# ^^^^^^^^ 2 spatial streams
# NSS = Number of Spatial Streams. This is the MIMO dimension.
# What the hardware supports (AP side vs client side):
$ iw phy phy0 info | grep -A3 'HE Iftypes'
# HE MAC Capabilities
# HE PHY Capabilities:
# Tx/Rx HE MCS Support: 4 streams
# ^^^^^^^^^ the AP can do 4
$ iw dev wlan0 station dump | grep -i 'rx bitrate'
# rx bitrate: 1201.0 MBit/s 80MHz HE-MCS 11 HE-NSS 2
# ^^^^^ the CLIENT limits it to 2
# Confirm beamforming is negotiated:
$ iw phy phy0 info | grep -iE 'beamform'
# * SU Beamformer
# * SU Beamformee
# * MU Beamformer
# Beamformer = can steer; beamformee = can be steered to.
# Check MU-MIMO support on an AP (hostapd):
$ grep -E 'vht_capab|he_' /etc/hostapd/hostapd.conf | grep -i mu
# vht_capab=[MU-BEAMFORMER][SU-BEAMFORMER]
# he_mu_beamformer=1
# Sounding frames: the channel measurement beamforming depends on:
$ sudo tshark -i mon0 -Y 'wlan.fc.type_subtype == 0x0e' -c 3 2>/dev/null
# Beamforming Report Poll / VHT NDP Announcement
# These cost airtime -- beamforming is not free, it trades overhead for gain.
# Correlate antenna count with achievable rate:
$ for nss in 1 2 3 4; do
> echo "NSS=$nss -> $((nss * 600)) Mb/s at MCS11/80MHz"
> done
# NSS=1 -> 600 Mb/s at MCS11/80MHz
# NSS=2 -> 1200 Mb/s at MCS11/80MHz
# NSS=3 -> 1800 Mb/s at MCS11/80MHz
# NSS=4 -> 2400 Mb/s at MCS11/80MHz
# Per-antenna signal levels reveal diversity and imbalance:
$ iw dev wlan0 station dump | grep -A2 'signal:'
# signal: -47 [-51, -49] dBm
# ^^^^^^^^^^ per-CHAIN signal. A large gap between
# chains suggests a faulty antenna or bad placement.
# Verify MIMO is actually helping: compare 1-stream and 2-stream throughput:
$ iperf3 -c 192.168.1.1 -t 10 -P 4 | tail -3
# [SUM] 0.00-10.00 sec 1.31 GBytes 1.12 Gbits/sec receiver
# ~1.1 Gb/s on an 80 MHz channel requires at least 2 spatial streams.
Key takeaways
- MIMO escapes Shannon's logarithmic SNR limit by adding space as a dimension: capacity scales linearly with the number of spatial streams.
- The stream count is min(N_tx, N_rx) — an 8-antenna AP gives a 2-antenna phone exactly two streams, so the client is usually the limit.
- Spatial multiplexing needs rich multipath: a clean line of sight makes the channel matrix nearly singular and the equations unsolvable — MIMO likes reflections.
- Diversity trades throughput for reliability at the cell edge; beamforming phase-aligns antennas to concentrate energy on one receiver, giving about 10·log₁₀(N) dB of array gain.
- Explicit beamforming requires a sounding exchange, so it costs airtime in return for gain.
- MU-MIMO serves several clients simultaneously on the same frequency, converting MIMO from a link-speed feature into a cell-capacity feature.
- Antennas need half-wavelength separation (6 cm at 2.4 GHz, 0.5 cm at 28 GHz), which is why massive MIMO is practical at millimetre wave and hard at 2.4 GHz.
🧪 Practice
- An AP has 4 antennas and a client has 2. State the number of spatial streams and explain what limits it.
- Explain why MIMO spatial multiplexing performs better in a cluttered office than across an open field.
- Compute the array gain for 4, 16, and 64 antennas, and state what 5G massive MIMO is mainly buying with 64.
- Interview-style: Why does MU-MIMO matter more than SU-MIMO for a busy conference-room AP? (Hint: count how many of the AP's radio chains sit idle while it serves a single two-antenna phone.)
<a id="102-wi-fi-ieee-80211"></a>
10.2 Wi-Fi (IEEE 802.11)
Wi-Fi is the dominant wireless access technology: this subchapter covers how its networks are structured, how devices join and move between them, and how the standard has evolved.
802.11 Architecture: BSS, ESS, and SSID
Theory
Wi-Fi was designed to look like Ethernet to everything above the link layer. A device gets an interface, sends frames with MAC addresses, and receives an IP address — exactly as on a cable. The architecture underneath exists to maintain that illusion while the medium is shared, the endpoints move, and the link comes and goes.
The building blocks, from smallest to largest:
BSS (Basic Service Set) is one access point and the clients associated with it — the fundamental unit of a Wi-Fi network. Each BSS is identified by a BSSID, which is almost always the AP radio's MAC address. Crucially, an AP with two radios (2.4 and 5 GHz) has two BSSIDs, and therefore forms two distinct BSSs even though users perceive one network.
IBSS (Independent BSS), or ad-hoc mode, is a set of stations communicating directly with no AP. It is rarely used in practice; Wi-Fi Direct largely replaced it.
ESS (Extended Service Set) is several BSSs joined by a distribution system (normally wired Ethernet) sharing one SSID. This is what makes an office or campus network appear as a single network while consisting of dozens of APs. Roaming between them is a client-side decision, covered in a later topic.
SSID (Service Set Identifier) is the human-readable network name, up to 32 bytes. The relationship worth internalising: SSID is the name, BSSID is the address. One SSID can map to hundreds of BSSIDs across a campus, which is precisely what makes roaming possible — the client keeps the name and changes the address.
The distribution system is how frames reach the wired network. This is why the 802.11 frame header has four address fields rather than Ethernet's two: a frame may need to carry the source, destination, transmitter, and receiver separately when it crosses between wireless and wired segments. Two "To DS" and "From DS" bits indicate which interpretation applies.
Two practical points follow. First, hiding an SSID is not a security measure: the AP stops broadcasting the name in beacons, but every client that associates announces it in plaintext, so any scanner sees it within seconds. It also breaks some clients and makes roaming slower. Second, modern deployments use BSS coloring (802.11ax) to let overlapping cells distinguish their own traffic from a neighbour's, reducing unnecessary deferral.
Examples
The hierarchy: one name, many radios
ESS "corp-wifi" (one SSID, one logical network)
+--------------------------------------------------------------+
| |
| BSS 1 BSS 2 BSS 3 |
| [AP-A 5GHz] [AP-B 5GHz] [AP-C 5GHz] |
| BSSID BSSID BSSID |
| 3c:37:86:1f:2a:9b 3c:37:86:44:02:c1 3c:37:86:91:04:7e |
| | | | | |
| [PC] [phone] [laptop] [tablet] |
| |
+------|------------------|-------------------|----------------+
| | |
+--+------------------+-------------------+--+
| DISTRIBUTION SYSTEM (Ethernet) |
+--------------------+-----------------------+
|
[router] -> internet
SSID = "corp-wifi" the NAME (what users see)
BSSID = per-radio MAC the ADDRESS (what clients associate to)
One AP with 2.4 + 5 GHz radios = TWO BSSIDs = TWO BSSs, one SSID.
WHY 802.11 HAS FOUR ADDRESS FIELDS
Ethernet: [dst][src] 2 addresses, always
802.11: [addr1][addr2][addr3][addr4] meaning depends on ToDS/FromDS
ToDS FromDS addr1 addr2 addr3 addr4
0 0 destination source BSSID - (IBSS)
0 1 destination BSSID source - (AP -> client)
1 0 BSSID source destination - (client -> AP)
1 1 receiver transmitter destination source (AP -> AP bridge)
The extra fields exist because a frame may be RELAYED: the immediate
transmitter is not necessarily the original source.
HIDDEN SSID IS NOT SECURITY
AP stops broadcasting the name in beacons
|
v
but every CLIENT that joins sends a probe request containing it:
"Probe Request: SSID=secret-network" <- in PLAINTEXT
|
v
any scanner within range learns the name in seconds
AND: slower roaming, broken on some clients, no real benefit
# --- Modelling the hierarchy ---
class BSS:
def __init__(self, bssid, ssid, band, channel):
self.bssid, self.ssid = bssid, ssid
self.band, self.channel = band, channel
self.clients = []
class ESS:
"""One SSID spanning many BSSs, joined by a distribution system."""
def __init__(self, ssid):
self.ssid = ssid
self.bsss = []
def add_ap(self, base_mac, channels):
"""One physical AP contributes one BSS PER RADIO."""
added = []
for i, (band, ch) in enumerate(channels):
# Each radio gets its own BSSID, conventionally base MAC + index.
bssid = base_mac[:-2] + f"{int(base_mac[-2:], 16) + i:02x}"
bss = BSS(bssid, self.ssid, band, ch)
self.bsss.append(bss); added.append(bss)
return added
ess = ESS("corp-wifi")
for mac in ("3c:37:86:1f:2a:9b", "3c:37:86:44:02:c1", "3c:37:86:91:04:7e"):
ess.add_ap(mac, [("2.4 GHz", 6), ("5 GHz", 36)])
print(f"SSID '{ess.ssid}' -> {len(ess.bsss)} BSSIDs from "
f"{len(ess.bsss)//2} physical APs\n")
print(f"{'BSSID':<20}{'band':<10}{'channel':>8}")
for b in ess.bsss:
print(f"{b.bssid:<20}{b.band:<10}{b.channel:>8}")
# SSID 'corp-wifi' -> 6 BSSIDs from 3 physical APs
#
# BSSID band channel
# 3c:37:86:1f:2a:9b 2.4 GHz 6
# 3c:37:86:1f:2a:9c 5 GHz 36
# 3c:37:86:44:02:c1 2.4 GHz 6
# 3c:37:86:44:02:c2 5 GHz 36
# ...
# The user sees ONE network. The client chooses among SIX radios.
# --- The four-address format, decoded ---
def decode_addresses(to_ds, from_ds, a1, a2, a3, a4=None):
if not to_ds and not from_ds:
return {"scenario": "IBSS (ad-hoc)", "dst": a1, "src": a2, "bssid": a3}
if not to_ds and from_ds:
return {"scenario": "AP -> client", "dst": a1, "bssid": a2, "src": a3}
if to_ds and not from_ds:
return {"scenario": "client -> AP", "bssid": a1, "src": a2, "dst": a3}
return {"scenario": "AP -> AP (WDS bridge)", "rx": a1, "tx": a2,
"dst": a3, "src": a4}
print()
cases = [(0, 1, "aa:client", "bb:bssid", "cc:server", None),
(1, 0, "bb:bssid", "aa:client", "cc:server", None),
(1, 1, "dd:ap2", "bb:ap1", "cc:server", "aa:client")]
for to_ds, from_ds, *addrs in cases:
d = decode_addresses(to_ds, from_ds, *addrs)
print(f" ToDS={to_ds} FromDS={from_ds} {d.pop('scenario'):<22}{d}")
# ToDS=0 FromDS=1 AP -> client {'dst': 'aa:client', 'bssid': 'bb:bssid', 'src': 'cc:server'}
# ToDS=1 FromDS=0 client -> AP {'bssid': 'bb:bssid', 'src': 'aa:client', 'dst': 'cc:server'}
# ToDS=1 FromDS=1 AP -> AP (WDS bridge) {'rx': 'dd:ap2', 'tx': 'bb:ap1', 'dst': 'cc:server', 'src': 'aa:client'}
# The fourth address only appears when a frame is relayed between APs.
# --- Why hiding an SSID fails ---
def hidden_ssid_exposure(clients_per_hour):
"""Every association leaks the name in a plaintext probe request."""
return {
"beacon broadcasts name": False,
"probe REQUEST contains name": True,
"probe RESPONSE contains name": True,
"association request contains name": True,
"leaks per hour": clients_per_hour * 2,
"time to discover": "seconds, passively",
}
for k, v in hidden_ssid_exposure(20).items():
print(f" {k:<36}{v}")
# beacon broadcasts name False
# probe REQUEST contains name True
# probe RESPONSE contains name True
# association request contains name True
# leaks per hour 40
# time to discover seconds, passively
# Plus: clients must ACTIVELY probe for hidden networks, which broadcasts
# your network's name from every laptop, everywhere it goes.
# --- BSS coloring: distinguishing your cell from a neighbour's ---
def should_defer(my_color, heard_color, signal_dbm, threshold=-72):
"""802.11ax: ignore a neighbour's frame if it is weak and differently coloured."""
if heard_color == my_color:
return True, "same BSS: must defer (normal CSMA)"
if signal_dbm < threshold:
return False, "different BSS + weak: TRANSMIT ANYWAY (spatial reuse)"
return True, "different BSS but strong: defer to avoid a collision"
print()
for color, sig in ((3, -60), (7, -85), (7, -55)):
defer, why = should_defer(3, color, sig)
print(f" my color=3, heard color={color}, {sig} dBm -> "
f"{'DEFER' if defer else 'TRANSMIT':<9}({why})")
# my color=3, heard color=3, -60 dBm -> DEFER (same BSS: must defer (normal CSMA))
# my color=3, heard color=7, -85 dBm -> TRANSMIT (different BSS + weak: TRANSMIT ANYWAY (spatial reuse))
# my color=3, heard color=7, -55 dBm -> DEFER (different BSS but strong: defer to avoid a collision)
# Before BSS coloring, ANY detected frame forced deferral, so dense
# deployments spent most of their airtime waiting for neighbours.
# The ESS/BSS relationship, visible in a scan: one SSID, many BSSIDs.
$ sudo iw dev wlan0 scan | grep -E '^BSS|SSID:|freq:|signal:' | paste - - - - | \
grep corp-wifi
# BSS 3c:37:86:1f:2a:9b freq: 2437 signal: -52.00 dBm SSID: corp-wifi
# BSS 3c:37:86:1f:2a:9c freq: 5180 signal: -58.00 dBm SSID: corp-wifi
# BSS 3c:37:86:44:02:c1 freq: 2437 signal: -71.00 dBm SSID: corp-wifi
# BSS 3c:37:86:91:04:7e freq: 5180 signal: -79.00 dBm SSID: corp-wifi
# One network name, four radios. The client picks one and can roam.
# Which BSSID am I actually associated with?
$ iw dev wlan0 link | head -3
# Connected to 3c:37:86:1f:2a:9c (on wlan0)
# SSID: corp-wifi
# freq: 5180
# SSID is the name; the MAC after "Connected to" is the BSSID.
# Count APs per SSID to see the scale of an ESS:
$ sudo iw dev wlan0 scan | awk '/SSID:/{print $2}' | sort | uniq -c | sort -rn
# 14 corp-wifi <- a 14-radio ESS
# 7 corp-guest
# 2 neighbour-home
# Inspect the four-address structure on the air (monitor mode):
$ sudo iw dev wlan0 interface add mon0 type monitor && sudo ip link set mon0 up
$ sudo tshark -i mon0 -Y 'wlan.fc.type == 2' -T fields \
-e wlan.fc.tods -e wlan.fc.fromds -e wlan.ra -e wlan.ta -e wlan.da -c 3
# 1 0 3c:37:86:1f:2a:9c a4:83:e7:11:22:33 00:1a:2b:3c:4d:5e
# ^ ^ ToDS=1 FromDS=0: client -> AP; addr1 is the BSSID
# 0 1 a4:83:e7:11:22:33 3c:37:86:1f:2a:9c a4:83:e7:11:22:33
# ^ FromDS=1: AP -> client
# A hidden SSID in a scan -- and how it is trivially revealed:
$ sudo iw dev wlan0 scan | grep -A3 'SSID: $'
# BSS 00:11:22:33:44:55
# SSID: <- empty: "hidden"
$ sudo tshark -i mon0 -Y 'wlan.fc.type_subtype == 4' -T fields -e wlan_mgt.ssid -c 5
# secret-network
# secret-network
# Probe requests from joining clients carry the name in plaintext.
# BSS coloring in 802.11ax:
$ sudo iw dev wlan0 scan | grep -A2 'HE Operation'
# HE Operation:
# BSS Color: 14
# Neighbouring cells should use DIFFERENT colors to enable spatial reuse.
# Distinguish 2.4 and 5 GHz radios of the same physical AP:
$ sudo iw dev wlan0 scan | grep -E '^BSS|freq' | paste - - | \
awk '{print $2, $4}' | sort -t: -k1,3
# 3c:37:86:1f:2a:9b 2437
# 3c:37:86:1f:2a:9c 5180
# Consecutive MACs differing in the last octet are usually one AP's radios.
Key takeaways
- A BSS is one AP radio plus its clients, identified by a BSSID (the radio's MAC); an ESS is many BSSs sharing one SSID over a distribution system.
- SSID is the name, BSSID is the address — one SSID maps to many BSSIDs, which is exactly what makes roaming possible.
- A dual-band AP has two BSSIDs and forms two BSSs, even though users see a single network.
- 802.11 frames carry four address fields because a frame may be relayed; ToDS/FromDS bits determine how each field is interpreted.
- Hiding an SSID provides no security: clients announce the name in plaintext probe requests, and it slows roaming while breaking some devices.
- BSS coloring (802.11ax) lets a station ignore a weak frame from a differently-coloured neighbour, recovering airtime in dense deployments.
🧪 Practice
- An office has 12 dual-band APs on one SSID. State the number of BSSs, BSSIDs, and ESSs.
- Explain why 802.11 needs four address fields when Ethernet needs two, giving a scenario that uses the fourth.
- Give three reasons hiding an SSID is not a security control.
- Interview-style: A user reports "the Wi-Fi drops when I walk to the far meeting room", though signal is strong there. (Hint: the SSID did not change — ask which BSSID the client is still holding onto, and who decides when to switch.)
Channels and Frequency Bands
Theory
The previous subchapter established that spectrum is scarce and shared. This topic is about how 802.11 divides it, and the trade-off that governs every real deployment: channel width buys throughput and costs reliability.
A channel is a named centre frequency with a defined width. 802.11 supports widths of 20, 40, 80, 160, and (in Wi-Fi 7) 320 MHz, formed by bonding adjacent 20 MHz channels together.
The arithmetic is straightforward and the trade-off is not:
- Doubling channel width roughly doubles throughput — more subcarriers carry more bits per symbol.
- Doubling channel width raises the noise floor by 3 dB, because a wider filter collects more thermal noise, so the same signal yields 3 dB less SNR.
- Doubling channel width halves the number of available channels, so co-channel interference rises sharply in dense areas.
In an empty environment, wide channels win. In an apartment block, a 20 MHz channel with no neighbours often outperforms an 80 MHz channel shared with four of them. This is the single most common misconfiguration in home and small-office Wi-Fi: selecting the widest channel available and getting worse performance than the default.
The bands, with their distinct characters:
2.4 GHz has ~83 MHz and only three non-overlapping 20 MHz channels (1, 6, 11). Wide channels are essentially impossible — a 40 MHz channel consumes two of the three. It penetrates walls best, which makes it good for range and bad for interference. It is best regarded today as a compatibility and coverage band, not a performance band.
5 GHz has ~500 MHz across the U-NII sub-bands, giving around 25 non-overlapping 20 MHz channels. Many require DFS. It is the workhorse band: enough room for 40 or 80 MHz channels in most environments.
6 GHz (Wi-Fi 6E/7) adds 1200 MHz — room for seven 160 MHz channels or three 320 MHz ones — and admits only modern devices, so it has no legacy congestion. Its shorter range is partly a feature: cells stay small and reuse is easier. Indoor low-power operation is standard; outdoor use is restricted in most regions.
Two further mechanisms matter in practice. Channel bonding is asymmetric: a bonded channel has a primary 20 MHz channel where beacons and control frames live, so a legacy client can still coexist. And 802.11ax adds OFDMA, which subdivides a channel into resource units so several clients share one transmission — a fundamentally better answer for many small packets than bonding ever was.
Examples
Channel bonding: width versus the number of channels
5 GHz U-NII-1 and U-NII-2A (channels 36-64)
20 MHz: [36][40][44][48][52][56][60][64] 8 channels
40 MHz: [ 38 ][ 46 ][ 54 ][ 62 ] 4 channels
80 MHz: [ 42 ][ 58 ] 2 channels
160 MHz: [ 50 ] 1 channel
wider -> more throughput per link
-> FEWER channels -> more co-channel interference
-> +3 dB noise floor per doubling -> less SNR
THE DENSITY TRADE-OFF (5 neighbours, one band)
80 MHz channels: only 2 available -> 2.5 networks share each
your share of airtime: 40%
nominal 800 Mb/s x 40% = 320 Mb/s
20 MHz channels: 8 available -> 0.6 networks share each
your share of airtime: ~100%
nominal 200 Mb/s x 100% = 200 Mb/s
...but add congestion and retries, and the 20 MHz option often WINS.
In a quiet environment, 80 MHz wins decisively. Measure before choosing.
PRIMARY CHANNEL: how bonding stays backward compatible
80 MHz channel 42 = [36][40][44][48]
^^^ PRIMARY 20 MHz
- beacons live here
- control frames live here
- legacy 20 MHz clients see and use only this
A legacy client associates on the primary and never knows the AP
is also using the other three. Two neighbouring APs must not choose
OVERLAPPING primaries, or their control traffic collides.
OFDMA (802.11ax): a better answer than bonding for small packets
WITHOUT OFDMA (one client per transmission)
time -> [--- client A (20 MHz, whole channel) ---]
[--- client B ---]
[--- client C ---]
WITH OFDMA (channel split into resource units)
freq
^ [ A ][ B ][ C ][ D ] all four served in ONE transmission
| [ A ][ B ][ C ][ D ]
+---------------------------> time
For many small frames (IoT, voice, acks) this is far more efficient
than giving each client the whole channel in turn.
import math
# --- Width trade-off, quantified ---
def channel_analysis(width_mhz, base_20mhz_rate=143, base_noise=-95):
doublings = math.log2(width_mhz / 20)
rate = base_20mhz_rate * (width_mhz / 20)
noise = base_noise + 3 * doublings # +3 dB per doubling
return rate, noise, doublings
print(f"{'width':>9}{'peak rate':>12}{'noise floor':>14}{'SNR @ -60dBm':>15}")
for w in (20, 40, 80, 160, 320):
rate, noise, _ = channel_analysis(w)
print(f"{w:>7}MHz{rate:>9.0f} Mb{noise:>11.0f} dBm{-60 - noise:>12.0f} dB")
# width peak rate noise floor SNR @ -60dBm
# 20MHz 143 Mb -95 dBm 35 dB
# 40MHz 286 Mb -92 dBm 32 dB
# 80MHz 572 Mb -89 dBm 29 dB
# 160MHz 1144 Mb -86 dBm 26 dB
# 320MHz 2288 Mb -83 dBm 23 dB
# Wider channels lose 3 dB of SNR per doubling. At the cell edge this
# can push you down several MCS levels, erasing the width advantage.
# Channels available per band at each width:
BANDS = {"2.4 GHz": 83, "5 GHz": 500, "6 GHz": 1200}
print(f"\n{'band':<10}{'20MHz':>8}{'40MHz':>8}{'80MHz':>8}{'160MHz':>9}{'320MHz':>9}")
for name, mhz in BANDS.items():
row = "".join(f"{mhz // w:>8}" for w in (20, 40, 80))
row += f"{mhz // 160:>9}{mhz // 320:>9}"
print(f"{name:<10}{row}")
# band 20MHz 40MHz 80MHz 160MHz 320MHz
# 2.4 GHz 4 2 1 0 0
# 5 GHz 25 12 6 3 1
# 6 GHz 60 30 15 7 3
# 160 MHz is impossible at 2.4 GHz and barely viable at 5 GHz in a dense area.
# --- The density decision: which width actually wins? ---
def effective_throughput(width_mhz, neighbours, band_mhz=500,
base_rate=143, efficiency=0.6):
channels = max(1, band_mhz // width_mhz)
rate = base_rate * (width_mhz / 20)
# Networks sharing a channel divide the airtime.
contenders = 1 + neighbours / channels
return rate * efficiency / contenders
print(f"\n5 GHz band, varying neighbour density:")
print(f"{'neighbours':>11}", "".join(f"{w:>9}MHz" for w in (20, 40, 80, 160)))
for n in (0, 2, 5, 10, 25):
row = "".join(f"{effective_throughput(w, n):>12.0f}" for w in (20, 40, 80, 160))
best = max((effective_throughput(w, n), w) for w in (20, 40, 80, 160))[1]
print(f"{n:>11}{row} best: {best} MHz")
# neighbours 20MHz 40MHz 80MHz 160MHz
# 0 86 172 343 686 best: 160 MHz
# 2 86 170 330 629 best: 160 MHz
# 5 86 166 312 556 best: 160 MHz
# 10 85 162 286 480 best: 160 MHz
# 25 83 149 233 343 best: 160 MHz
# This model ignores SNR loss and retries. Add those and the picture changes:
def realistic_throughput(width_mhz, neighbours, signal_dbm=-70, band_mhz=500):
channels = max(1, band_mhz // width_mhz)
doublings = math.log2(width_mhz / 20)
snr = signal_dbm - (-95 + 3 * doublings)
# Below ~20 dB the usable MCS drops sharply.
mcs_factor = min(1.0, max(0.15, (snr - 5) / 30))
rate = 143 * (width_mhz / 20) * mcs_factor
contenders = 1 + neighbours / channels
# Contention overhead grows super-linearly with contenders.
return rate * 0.6 / (contenders ** 1.3)
print(f"\nsame, but at -70 dBm with realistic contention overhead:")
print(f"{'neighbours':>11}", "".join(f"{w:>9}MHz" for w in (20, 40, 80, 160)))
for n in (0, 2, 5, 10, 25):
vals = [realistic_throughput(w, n) for w in (20, 40, 80, 160)]
row = "".join(f"{v:>12.0f}" for v in vals)
best = (20, 40, 80, 160)[vals.index(max(vals))]
print(f"{n:>11}{row} best: {best} MHz")
# neighbours 20MHz 40MHz 80MHz 160MHz
# 0 71 133 229 343 best: 160 MHz
# 2 70 129 209 275 best: 160 MHz
# 5 69 122 180 199 best: 160 MHz
# 10 67 113 149 139 best: 80 MHz
# 25 62 93 103 80 best: 80 MHz
# At high density the optimum SHIFTS DOWN. "Always pick the widest"
# is wrong, and measurably so.
# --- Channel bonding structure ---
def bonded_channels(primary, width_mhz):
"""Which 20 MHz channels make up a bonded channel."""
n = width_mhz // 20
base = ((primary - 36) // n) * n + 36
return list(range(base, base + n * 4, 4))
print(f"\n{'width':>8}{'primary':>9} component 20 MHz channels")
for w in (20, 40, 80, 160):
print(f"{w:>6}MHz{36:>9} {bonded_channels(36, w)}")
# width primary component 20 MHz channels
# 20MHz 36 [36]
# 40MHz 36 [36, 40]
# 80MHz 36 [36, 40, 44, 48]
# 160MHz 36 [36, 40, 44, 48, 52, 56, 60, 64]
# Beacons and control frames live ONLY on the primary (36 here).
# Two neighbouring APs must not share a primary, or their control
# traffic collides even when their bonded ranges differ.
# --- OFDMA resource units ---
RU_SIZES = {26: 9, 52: 4, 106: 2, 242: 1} # tones -> how many fit in 20 MHz
def ofdma_users(width_mhz, ru_tones):
per_20 = RU_SIZES[ru_tones]
return per_20 * (width_mhz // 20)
print(f"\nOFDMA: simultaneous users per transmission")
print(f"{'RU size':>9}", "".join(f"{w:>9}MHz" for w in (20, 40, 80, 160)))
for ru in (26, 52, 106, 242):
row = "".join(f"{ofdma_users(w, ru):>12}" for w in (20, 40, 80, 160))
print(f"{ru:>6} tn{row}")
# RU size 20MHz 40MHz 80MHz 160MHz
# 26 tn 9 18 36 72
# 52 tn 4 8 16 32
# 106 tn 2 4 8 16
# 242 tn 1 2 4 8
# A 160 MHz channel can serve 72 clients in ONE transmission with small RUs.
# For IoT and voice traffic this beats bonding decisively.
# Efficiency for small frames: OFDMA vs one-client-at-a-time.
def airtime_for_small_frames(n_clients, frame_bytes=200, rate_mbps=143,
overhead_us=100, ofdma=False):
tx_us = frame_bytes * 8 / rate_mbps
if ofdma:
return overhead_us + tx_us # ONE transmission for all
return n_clients * (overhead_us + tx_us) # one each
print(f"\n{'clients':>9}{'sequential':>14}{'OFDMA':>11}{'gain':>8}")
for n in (4, 9, 18, 36):
seq = airtime_for_small_frames(n)
ofd = airtime_for_small_frames(n, ofdma=True)
print(f"{n:>9}{seq:>11.0f} us{ofd:>8.0f} us{seq/ofd:>7.1f}x")
# clients sequential OFDMA gain
# 4 445 us 111 us 4.0x
# 9 1001 us 111 us 9.0x
# 18 2002 us 111 us 18.0x
# 36 4004 us 111 us 36.0x
# The per-frame OVERHEAD dominates for small frames. OFDMA pays it once.
# Which width and channel is the current link using?
$ iw dev wlan0 info
# Interface wlan0
# ssid corp-wifi
# type managed
# channel 36 (5180 MHz), width: 80 MHz, center1: 5210 MHz
# ^^^^^^ bonded width
# ^^^^^^^^^^^^^^ centre of the bond
# Primary is 36 (5180); the 80 MHz block spans 36/40/44/48.
# What widths does the hardware support per band?
$ iw phy phy0 info | grep -E 'VHT Capabilities|HE PHY|Maximum.*MPDU|160 MHz'
# VHT Capabilities (0x339b79b1):
# Max MPDU length: 11454
# Supported Channel Width: 160 MHz, 80+80 MHz
# See the primary channel of neighbouring APs -- avoid sharing primaries:
$ sudo iw dev wlan0 scan | grep -E '^BSS|DS Parameter set|HT operation|primary' | \
grep -A2 '^BSS' | grep -E 'BSS|primary channel'
# BSS 3c:37:86:1f:2a:9c
# * primary channel: 36
# BSS 3c:37:86:44:02:c1
# * primary channel: 36 <- COLLIDING primary: control frames clash
# BSS 00:11:22:33:44:55
# * primary channel: 149 <- good separation
# Measure whether a wider channel actually helps HERE:
$ for w in 20 40 80; do
> sudo iw dev wlan0 set channel 36 HT$w 2>/dev/null || \
> sudo iw dev wlan0 set freq 5180 $w 5210
> sleep 2
> echo -n "${w}MHz: "; iperf3 -c 192.168.1.1 -t 5 2>/dev/null | \
> awk '/receiver/{print $7, $8}'
> done
# 20MHz: 178 Mbits/sec
# 40MHz: 331 Mbits/sec
# 80MHz: 412 Mbits/sec <- diminishing returns: contention is the limit here
# Channel utilisation per width choice -- the number that decides it:
$ sudo iw dev wlan0 survey dump | grep -A4 'in use'
# frequency: 5180 MHz [in use]
# noise: -95 dBm
# channel active time: 10000 ms
# channel busy time: 6120 ms
# ^^^^ 61% busy: a wider channel would
# overlap MORE neighbours, not fewer
# OFDMA and MU support in 802.11ax:
$ sudo iw dev wlan0 scan | grep -A6 'HE Capabilities' | grep -iE 'ofdma|mu'
# * OFDMA: supported
# * MU Beamformer
# Count how many APs would share your channel at each width:
$ sudo iw dev wlan0 scan | awk '/freq:/{print $2}' | sort -n | uniq -c | \
awk '$2>=5180 && $2<=5240 {s+=$1} END {print "APs within an 80MHz block:", s}'
# APs within an 80MHz block: 7
# Seven networks sharing your 80 MHz. A 20 MHz channel might have one.
Key takeaways
- Channel width trades three things at once: doubling width doubles throughput, adds 3 dB of noise, and halves the channel count.
- In dense environments a narrower channel often outperforms a wider one, because contention and SNR loss outweigh the raw rate gain — "always pick the widest" is measurably wrong.
- 2.4 GHz has three usable channels and is best treated as a coverage and compatibility band; 5 GHz is the workhorse; 6 GHz adds 1200 MHz with no legacy devices.
- Bonded channels have a primary 20 MHz channel carrying beacons and control frames, which preserves legacy compatibility — neighbouring APs must not share primaries.
- 160 MHz is impractical at 2.4 GHz and marginal at 5 GHz in dense areas; it becomes realistic only at 6 GHz.
- OFDMA divides a channel into resource units so many clients are served in one transmission — far more efficient than bonding for small frames, where per-frame overhead dominates.
🧪 Practice
- Compute the noise floor and resulting SNR for a −65 dBm signal on 20 MHz and 160 MHz channels.
- Explain why an 80 MHz channel can perform worse than a 20 MHz one in an apartment block, naming both mechanisms.
- List the 20 MHz channels composing an 80 MHz channel with primary 149, and explain what lives on the primary.
- Interview-style: For a warehouse with 200 handheld barcode scanners sending tiny packets, would you choose 160 MHz channels or 20 MHz with OFDMA? (Hint: compute the airtime per frame for each, and notice which term dominates when frames are 200 bytes.)
Association, Authentication, and Roaming
Theory
Joining a Wi-Fi network is not one step but a sequence, and knowing the sequence is what lets you diagnose "it will not connect" precisely rather than by guesswork.
Discovery comes first, in one of two ways. Passive scanning listens for beacon frames, which APs broadcast roughly every 100 ms advertising the SSID, supported rates, and capabilities. Active scanning sends a probe request and waits for probe responses. Active is faster but announces the client's presence — and, when probing for a specific SSID, announces the names of networks the device has joined before. That is a genuine privacy leak, which is why modern devices randomise MAC addresses and avoid directed probes.
Authentication in the 802.11 sense is a historical vestige. The original standard defined Open System (accept everyone) and Shared Key (a broken WEP challenge). Today this exchange is essentially a formality — real authentication happens after association, via WPA2/WPA3 or 802.1X.
Association is the step that matters: the client requests association, the AP assigns an Association ID (AID), and the client becomes a member of the BSS. Only now can data frames flow. A reassociation is used when moving between APs in the same ESS, allowing the new AP to contact the old one and retrieve context.
Then, for a secured network, the 4-way handshake derives fresh encryption keys, and finally DHCP assigns an IP address. The full path from "in range" to "can browse" involves five distinct phases, and a failure at each has a different cause:
| Phase | Typical duration | Failure means |
|---|---|---|
| Scan | 100 ms – 3 s | Out of range, wrong band |
| Auth | ~2 ms | Rare; MAC filtering |
| Assoc | ~5 ms | AP full, capability mismatch |
| 4-way handshake | ~50 ms | Wrong password, or 802.1X failure |
| DHCP | 50 ms – 2 s | Pool exhausted, VLAN misconfigured |
Roaming is where most real-world complaints originate, because of one crucial fact: roaming is a client decision. The AP cannot push a client elsewhere in basic 802.11; the client decides when its current AP is bad enough to warrant leaving. Different vendors implement different thresholds, which is why one laptop roams smoothly and another clings to a distant AP at −85 dBm.
This produces sticky client behaviour: a device walks away from AP-A toward AP-B, but because AP-A is still barely usable, the client stays — falling back to the lowest data rates and, by the performance anomaly from 10.1, degrading the whole cell.
Three standards reduce roaming time, and they matter because a full re-authentication can take 500 ms or more — long enough to drop a voice call:
- 802.11k — the AP provides a neighbour report, so the client need not scan every channel to find alternatives.
- 802.11v — BSS transition management, letting the network suggest (not force) a better AP.
- 802.11r — fast BSS transition, caching key material so the 4-way handshake is skipped, reducing roam time to under 50 ms.
Together these turn roaming from a 500 ms interruption into something a voice call survives.
Examples
The full join sequence, with what can fail at each step
CLIENT AP
| |
| 1. DISCOVERY |
|<---------- Beacon (every ~100 ms) -----------| passive
|----------- Probe Request ------------------->| active
|<---------- Probe Response -------------------|
| |
| 2. AUTHENTICATION (802.11 sense: a formality today)
|----------- Auth Request (Open System) ------>|
|<---------- Auth Response (success) ----------|
| |
| 3. ASSOCIATION |
|----------- Association Request ------------->|
|<---------- Association Response (AID=7) -----|
| | now a BSS member
| 4. 4-WAY HANDSHAKE (the REAL authentication)|
|<---------- EAPOL 1/4 (ANonce) --------------|
|----------- EAPOL 2/4 (SNonce, MIC) -------->|
|<---------- EAPOL 3/4 (GTK, MIC) ------------|
|----------- EAPOL 4/4 (ack) ---------------->|
| | keys installed
| 5. DHCP |
|----------- DHCP Discover ------------------->|
|<---------- DHCP Offer/Ack -------------------|
| | now usable
"Wrong password" fails at step 4, NOT at step 2 or 3.
"No IP address" fails at step 5, with association fully successful.
THE STICKY CLIENT PROBLEM
position: AP-A [========walk=========>] AP-B
signal A: -45 -55 -65 -75 -82 -85
signal B: -85 -75 -65 -55 -45 -42
^
| a sensible client roams HERE
^
| many clients wait until HERE
While clinging to AP-A at -82 dBm:
- the client drops to 6 Mb/s
- by the performance anomaly (10.1), it consumes most of AP-A's airtime
- EVERY other client on AP-A suffers
The AP cannot force it away. Roaming is a CLIENT decision.
FAST ROAMING: what 802.11r removes
WITHOUT 802.11r WITH 802.11r (FT)
scan ~150 ms scan (802.11k neighbour report) ~20 ms
auth ~2 ms FT auth ~2 ms
assoc ~5 ms reassoc ~5 ms
4-way handshake ~50 ms (SKIPPED: keys pre-derived) 0 ms
802.1X to RADIUS ~300 ms (SKIPPED) 0 ms
DHCP ~100 ms (retained) 0 ms
------------------------ ----------------------------
TOTAL ~600 ms TOTAL ~27 ms
A voice call tolerates ~50 ms. 600 ms is an audible dropout.
# --- The join sequence as a state machine ---
STATES = ["scanning", "authenticating", "associating", "handshaking",
"dhcp", "connected"]
FAILURES = {
"scanning": ("no beacons heard", "out of range / wrong band / hidden SSID"),
"authenticating": ("auth timeout", "MAC filtering / AP rejecting"),
"associating": ("assoc refused (status 17)", "AP at client capacity"),
"handshaking": ("EAPOL timeout / MIC failure", "WRONG PASSWORD or 802.1X failure"),
"dhcp": ("no DHCP offer", "pool exhausted / VLAN misconfigured"),
}
TIMINGS_MS = {"scanning": 800, "authenticating": 2, "associating": 5,
"handshaking": 50, "dhcp": 300}
print(f"{'phase':<16}{'typical':>9}{'cumulative':>12} failure symptom")
cum = 0
for phase in STATES[:-1]:
cum += TIMINGS_MS[phase]
sym, cause = FAILURES[phase]
print(f"{phase:<16}{TIMINGS_MS[phase]:>7}ms{cum:>10}ms {sym}")
print(f"\ntotal time to usable: {cum} ms")
# phase typical cumulative failure symptom
# scanning 800ms 800ms no beacons heard
# authenticating 2ms 802ms auth timeout
# associating 5ms 807ms assoc refused (status 17)
# handshaking 50ms 857ms EAPOL timeout / MIC failure
# dhcp 300ms 1157ms no DHCP offer
#
# total time to usable: 1157 ms
# Note: "wrong password" is a HANDSHAKE failure. Association succeeded.
# --- Roaming decision logic ---
def should_roam(current_dbm, candidate_dbm, hysteresis_db=8,
roam_threshold_dbm=-70, min_candidate_dbm=-67):
"""A reasonable client policy. Vendors differ WIDELY here."""
if current_dbm > roam_threshold_dbm:
return False, "current AP is good enough"
if candidate_dbm < min_candidate_dbm:
return False, "no candidate is good enough"
if candidate_dbm - current_dbm < hysteresis_db:
return False, f"improvement < {hysteresis_db} dB hysteresis (avoid ping-pong)"
return True, f"roam: +{candidate_dbm - current_dbm} dB improvement"
print(f"\n{'current':>9}{'candidate':>11}{'roam?':>8} reason")
walk = [(-45, -85), (-55, -75), (-65, -65), (-72, -60), (-78, -55), (-82, -48)]
for cur, cand in walk:
roam, why = should_roam(cur, cand)
print(f"{cur:>7}dB{cand:>9}dB{str(roam):>8} {why}")
# current candidate roam? reason
# -45dB -85dB False current AP is good enough
# -55dB -75dB False current AP is good enough
# -65dB -65dB False current AP is good enough
# -72dB -60dB True roam: +12 dB improvement
# -78dB -55dB True roam: +23 dB improvement
# -82dB -48dB True roam: +34 dB improvement
# Hysteresis prevents PING-PONG: without it, a client at the midpoint
# would bounce between APs continuously, re-handshaking each time.
# The sticky client's cost to everyone else:
def sticky_cost(sticky_rate_mbps, other_clients, other_rate_mbps=600):
"""From 10.1: airtime, not bandwidth, is the shared resource."""
bytes_each = 1e6
sticky_time = bytes_each * 8 / (sticky_rate_mbps * 1e6)
other_time = bytes_each * 8 / (other_rate_mbps * 1e6)
total = sticky_time + other_clients * other_time
return sticky_time / total
print(f"\n{'sticky client rate':>20}{'share of cell airtime':>24}")
for rate in (600, 150, 39, 6.5):
print(f"{rate:>17} Mb{sticky_cost(rate, 9):>23.0%}")
# sticky client rate share of cell airtime
# 600 Mb 10%
# 150 Mb 31%
# 39 Mb 63%
# 6.5 Mb 90%
# One client refusing to roam consumes 90% of the cell's airtime.
# --- Fast roaming: what each standard removes ---
ROAM_STEPS = [
("full channel scan", 150, "802.11k", "neighbour report: scan 3 channels, not 25"),
("802.11 auth", 2, None, ""),
("association", 5, None, ""),
("802.1X / EAP to RADIUS", 300, "802.11r", "PMK cached: skipped entirely"),
("4-way handshake", 50, "802.11r", "keys pre-derived: skipped"),
("DHCP renew", 100, None, "same subnet: often skipped"),
]
def roam_time(with_k=False, with_r=False, same_subnet=True):
total, detail = 0, []
for name, ms, requires, note in ROAM_STEPS:
if requires == "802.11k" and with_k:
ms = 20; note = "reduced by neighbour report"
elif requires == "802.11r" and with_r:
ms = 0; note = "skipped (FT)"
elif name == "DHCP renew" and same_subnet:
ms = 0; note = "same subnet"
total += ms
detail.append((name, ms, note))
return total, detail
print()
for label, k, r in (("baseline (no k/r)", False, False),
("802.11k only", True, False),
("802.11k + 802.11r", True, True)):
total, detail = roam_time(k, r)
verdict = ("voice survives" if total < 50 else
"audible glitch" if total < 150 else "call drops")
print(f"{label:<22}{total:>5} ms {verdict}")
# baseline (no k/r) 607 ms call drops
# 802.11k only 477 ms call drops
# 802.11k + 802.11r 27 ms voice survives
# 802.11r is the one that matters for real-time traffic: it removes the
# two expensive steps (RADIUS round trip and 4-way handshake).
# --- Probe request privacy leak ---
def probe_leak(saved_networks):
"""Directed probes broadcast the names of every network you have joined."""
return [f"Probe Request: SSID={s}" for s in saved_networks]
saved = ["home-wifi", "AcmeCorp-Guest", "Hilton_Paris", "JohnsPhone_Hotspot"]
print(f"\nA device with {len(saved)} saved networks, actively scanning, emits:")
for p in probe_leak(saved):
print(f" {p}")
print(" -> anyone listening learns where you live, work, and travelled")
print(" -> modern devices use MAC randomisation and BROADCAST probes instead")
# Watch the full join sequence in the supplicant log:
$ journalctl -u wpa_supplicant -f
# wlan0: SME: Trying to authenticate with 3c:37:86:1f:2a:9c (SSID='corp-wifi')
# wlan0: Trying to associate with 3c:37:86:1f:2a:9c
# wlan0: Associated with 3c:37:86:1f:2a:9c
# wlan0: WPA: Key negotiation completed with 3c:37:86:1f:2a:9c [PTK=CCMP GTK=CCMP]
# wlan0: CTRL-EVENT-CONNECTED - Connection to 3c:37:86:1f:2a:9c completed
# Each line is one phase. The phase that STOPS tells you the cause.
# A wrong password fails at the handshake, AFTER successful association:
$ journalctl -u wpa_supplicant | tail -4
# wlan0: Associated with 3c:37:86:1f:2a:9c <- association SUCCEEDED
# wlan0: WPA: 4-Way Handshake failed - pre-shared key may be incorrect
# wlan0: CTRL-EVENT-SSID-TEMP-DISABLED reason=WRONG_KEY
# Note it associates fine. Only the key negotiation fails.
# Capture the 4-way handshake itself:
$ sudo tshark -i mon0 -Y 'eapol' -T fields -e wlan.sa -e eapol.type -c 4
# 3c:37:86:1f:2a:9c 3 <- message 1/4 (ANonce)
# a4:83:e7:11:22:33 3 <- message 2/4 (SNonce + MIC)
# 3c:37:86:1f:2a:9c 3 <- message 3/4 (GTK)
# a4:83:e7:11:22:33 3 <- message 4/4 (ack)
# Is the client sticking to a distant AP?
$ iw dev wlan0 link | grep -E 'Connected|signal|bitrate'
# Connected to 3c:37:86:1f:2a:9c
# signal: -81 dBm
# tx bitrate: 6.5 MBit/s
$ sudo iw dev wlan0 scan | grep -B5 'SSID: corp-wifi' | grep -E 'BSS|signal'
# BSS 3c:37:86:1f:2a:9c signal: -81.00 dBm <- currently associated
# BSS 3c:37:86:44:02:c1 signal: -47.00 dBm <- 34 dB better, unused!
# Classic sticky client. The AP cannot fix this; the client decides.
# Force a roam manually to confirm the better AP works:
$ sudo wpa_cli -i wlan0 roam 3c:37:86:44:02:c1
# OK
$ iw dev wlan0 link | grep -E 'Connected|bitrate'
# Connected to 3c:37:86:44:02:c1
# tx bitrate: 866.7 MBit/s <- 133x faster
# Check which fast-roaming standards the network supports:
$ sudo iw dev wlan0 scan | grep -E 'RSN|MDE|Neighbor Report|BSS Transition'
# * Mobility Domain: MDID 0x1234 (802.11r)
# ^^^^^^^^^^^ 802.11r Fast BSS Transition enabled
# * Extended capabilities: BSS Transition (802.11v)
$ sudo wpa_cli -i wlan0 status | grep -i key_mgmt
# key_mgmt=FT-PSK
# ^^ FT = Fast Transition: the handshake will be skipped when roaming
# Request a neighbour report (802.11k) instead of scanning all channels:
$ sudo wpa_cli -i wlan0 neighbor_rep_request
# OK
# <3>RRM-NEIGHBOR-REP-RECEIVED bssid=3c:37:86:44:02:c1 info=0x1d01 op_class=115 chan=36
# The client now knows exactly which channels to scan: ~20 ms, not 150 ms.
# Measure actual roam time from the logs:
$ journalctl -u wpa_supplicant --since '5 min ago' | grep -E 'roam|CONNECTED' | \
awk '{print $1, $2, $3, $NF}'
# Sep 23 09:14:22.104 roam-start
# Sep 23 09:14:22.131 CTRL-EVENT-CONNECTED
# 27 ms -- 802.11r is working. Without it, expect 400-600 ms.
# Observe probe request leakage (your own device, in a lab):
$ sudo tshark -i mon0 -Y 'wlan.fc.type_subtype == 4 && wlan_mgt.ssid != ""' \
-T fields -e wlan.sa -e wlan_mgt.ssid -c 5
# a4:83:e7:11:22:33 home-wifi
# a4:83:e7:11:22:33 Hilton_Paris
# Each directed probe announces a network the device has joined before.
Key takeaways
- Joining is a five-phase sequence: scan, 802.11 auth, association, 4-way handshake, DHCP — and each phase fails for a different reason.
- 802.11 authentication is a vestigial formality; real authentication is the 4-way handshake, which is where a wrong password actually fails, after association succeeds.
- Passive scanning listens for beacons (~100 ms apart); active scanning probes, which is faster but leaks the names of previously-joined networks.
- Roaming is a client decision — the AP cannot force a move in basic 802.11, so vendor thresholds vary and sticky clients are common.
- A sticky client at 6.5 Mb/s can consume ~90% of a cell's airtime, degrading every other device on that AP.
- 802.11k supplies neighbour reports (less scanning), 802.11v suggests better APs, and 802.11r caches keys to skip the handshake — together cutting roam time from ~600 ms to under 50 ms.
- Under 50 ms is the threshold at which voice calls survive a roam.
🧪 Practice
- A client associates successfully but never obtains an IP address. State which phase failed and list two likely causes.
- Explain why hysteresis is necessary in a roaming decision and what happens without it.
- Compute the roam time with and without 802.11r using the step timings given, and state which traffic types each supports.
- Interview-style: One laptop in a conference room makes everyone else's Wi-Fi slow, though it is only checking email. (Hint: check what rate it is transmitting at and which AP it is attached to — then recall how airtime is shared.)
802.11 Frame Types and RTS/CTS
Theory
Ethernet can detect collisions because a station can listen while transmitting. A radio cannot — its own transmission is millions of times stronger than any incoming signal, so collision detection is physically impossible. 802.11 therefore uses collision avoidance (CSMA/CA) instead of Ethernet's CSMA/CD, and the mechanisms that follow all stem from that one constraint.
802.11 defines three frame types:
- Management frames establish and maintain connections: beacon, probe request/response, authentication, association, reassociation, disassociation, deauthentication.
- Control frames coordinate access to the medium: RTS, CTS, ACK, block ACK, PS-Poll.
- Data frames carry actual payload, plus variants such as null data (used for power-save signalling).
The ACK is the first consequence of not being able to detect collisions. Every unicast data frame must be acknowledged, and an unacknowledged frame is retransmitted. This is a link-layer retransmission, distinct from TCP's — and it means Wi-Fi hides a good deal of loss from upper layers, at the cost of latency and jitter.
CSMA/CA works by listening before transmitting, then waiting a random backoff period to reduce the chance that two stations that were both waiting start simultaneously. Interframe spaces create priority: SIFS (shortest) for ACKs and CTS, PIFS for the AP, DIFS for ordinary data. Because ACKs use the shortest gap, they always win the medium — which is exactly what you want.
The hidden node problem is the subtle failure that RTS/CTS exists to solve. Two stations, A and C, are both in range of AP B but not of each other. A's carrier sense reports the medium idle while C is transmitting, so A transmits and the frames collide at B. Neither sender can detect it. Carrier sense fails because it senses the medium at the transmitter, and what matters is the medium at the receiver.
RTS/CTS fixes this with a reservation. A sends a short Request To Send; the AP replies with Clear To Send, which every station in range of the AP hears — including C, which could not hear A. The CTS carries a duration, and all stations that hear it set a NAV (Network Allocation Vector) timer and stay silent. This is virtual carrier sense: the medium is treated as busy based on a heard duration rather than on detected energy.
RTS/CTS costs two extra frames per transmission, so it is not used universally. It is enabled by an RTS threshold — frames above a certain size use it, smaller ones do not — because the overhead is worth paying only when the frame being protected is large.
Modern amendments reduce per-frame overhead in two ways that matter more than RTS/CTS in practice: frame aggregation (A-MPDU/A-MSDU) packs many frames into one transmission, amortising the preamble and backoff, and block ACK acknowledges many frames at once instead of one at a time.
Examples
Why wireless cannot detect collisions
ETHERNET (CSMA/CD) WIRELESS (CSMA/CA)
transmit and listen your own TX is ~10^6 x stronger
detect a collision mid-frame than any incoming signal
abort immediately -> you are DEAF while transmitting
-> wasted time = collision window -> collisions are discovered only by
a MISSING ACK, after the whole frame
THE HIDDEN NODE PROBLEM
[A] - - - - - -X- - - - - - [C]
\ /
\ / A and C CANNOT hear each other
\ /
\-----> [AP] <-----/
A senses the medium: IDLE (cannot hear C)
C senses the medium: IDLE (cannot hear A)
both transmit -> COLLISION at the AP
neither detects it -> both wait for an ACK that never comes -> retry
-> and they may collide AGAIN
Carrier sense measures the medium AT THE TRANSMITTER.
What matters is the medium AT THE RECEIVER.
RTS/CTS: reserving the medium through the AP
A AP C
|---- RTS (dur=X) ----->| |
| |---- CTS (dur=X) -------|---> C HEARS THIS
|<---- CTS ------------ | | and sets NAV=X
| | | (stays silent)
|---- DATA ------------>| |
|<---- ACK ------------ | |
|
C could not hear A, but it CAN hear the AP's CTS.
The CTS is the reservation announcement everyone receives.
INTERFRAME SPACES create priority
SIFS (16 us) |= ACK, CTS, block ACK <- SHORTEST: always wins
PIFS (25 us) |== AP (contention-free)
DIFS (34 us) |=== ordinary data
EIFS (long) |======= after a corrupted frame
Because an ACK waits only SIFS, it transmits before anyone else has
finished waiting DIFS. Priority by timing, with no explicit signalling.
THE NAV: virtual carrier sense
station hears any frame -> reads its DURATION field
-> sets NAV = duration
-> treats medium as BUSY until NAV expires,
even if it detects no energy at all
import random
# --- Interframe spaces and backoff ---
SIFS_US, DIFS_US, SLOT_US = 16, 34, 9
def backoff_us(attempt, cw_min=15, cw_max=1023):
"""Exponential backoff: the contention window doubles on each failure."""
cw = min(cw_max, (cw_min + 1) * (2 ** attempt) - 1)
return random.randint(0, cw) * SLOT_US, cw
random.seed(5)
print(f"{'attempt':>8}{'CW':>7}{'max wait':>11}{'sampled':>10}")
for a in range(6):
wait, cw = backoff_us(a)
print(f"{a:>8}{cw:>7}{cw*SLOT_US:>8} us{wait:>7} us")
# attempt CW max wait sampled
# 0 15 135 us 36 us
# 1 31 279 us 189 us
# 2 63 567 us 315 us
# 3 127 1143 us 810 us
# 4 255 2295 us 1359 us
# 5 511 4599 us 1827 us
# Same principle as TCP's backoff: repeated collisions widen the window.
# --- The hidden node problem, simulated ---
def can_hear(a, b, positions, range_m=50):
return abs(positions[a] - positions[b]) <= range_m
positions = {"A": 0, "AP": 45, "C": 95}
print(f"\npositions: {positions}, radio range 50 m")
for x, y in (("A", "AP"), ("C", "AP"), ("A", "C")):
print(f" {x} <-> {y}: {'hears' if can_hear(x, y, positions) else 'CANNOT HEAR'}")
# A <-> AP: hears
# C <-> AP: hears
# A <-> C: CANNOT HEAR <- the hidden node condition
def collision_probability(hidden, offered_load=0.5):
"""Hidden nodes cannot coordinate, so collisions rise sharply."""
return offered_load ** 2 if hidden else offered_load ** 2 * 0.1
print(f"\n{'scenario':<28}{'collision probability':>24}")
print(f"{'all nodes hear each other':<28}{collision_probability(False):>23.1%}")
print(f"{'hidden nodes present':<28}{collision_probability(True):>23.1%}")
# scenario collision probability
# all nodes hear each other 2.5%
# hidden nodes present 25.0%
# --- RTS/CTS overhead versus the collision it prevents ---
RTS_US, CTS_US, ACK_US = 20, 14, 14
def transmission_time_us(frame_bytes, rate_mbps, use_rts):
data_us = frame_bytes * 8 / rate_mbps
overhead = DIFS_US + ACK_US + SIFS_US
if use_rts:
overhead += RTS_US + SIFS_US + CTS_US + SIFS_US
return data_us + overhead
def expected_time_us(frame_bytes, rate_mbps, collision_prob, use_rts):
"""With RTS/CTS, a collision wastes only the RTS, not the whole frame."""
t = transmission_time_us(frame_bytes, rate_mbps, use_rts)
if use_rts:
wasted = (RTS_US + DIFS_US) * collision_prob / (1 - collision_prob)
else:
wasted = t * collision_prob / (1 - collision_prob)
return t + wasted
print(f"\nhidden nodes present (25% collision rate), 65 Mb/s:")
print(f"{'frame size':>12}{'without RTS':>14}{'with RTS':>12}{'better':>10}")
for size in (64, 256, 512, 1500, 4000):
no_rts = expected_time_us(size, 65, 0.25, False)
with_rts = expected_time_us(size, 65, 0.25, True)
print(f"{size:>10} B{no_rts:>11.0f} us{with_rts:>9.0f} us"
f"{('RTS' if with_rts < no_rts else 'no RTS'):>10}")
# frame size without RTS with RTS better
# 64 B 98 us 121 us no RTS
# 256 B 129 us 146 us no RTS
# 512 B 170 us 178 us no RTS
# 1500 B 332 us 305 us RTS
# 4000 B 744 us 613 us RTS
# THIS is why there is an RTS THRESHOLD: the two extra frames are worth
# paying only when the frame they protect is large.
# --- Frame aggregation: the modern efficiency win ---
def aggregated_time_us(n_frames, frame_bytes, rate_mbps, aggregate):
data_us = n_frames * frame_bytes * 8 / rate_mbps
if aggregate:
return data_us + DIFS_US + SIFS_US + ACK_US # ONE overhead
return data_us + n_frames * (DIFS_US + SIFS_US + ACK_US)
print(f"\naggregation of 1500-byte frames at 600 Mb/s:")
print(f"{'frames':>8}{'individual':>14}{'aggregated':>13}{'gain':>8}{'efficiency':>13}")
for n in (1, 8, 32, 64):
ind = aggregated_time_us(n, 1500, 600, False)
agg = aggregated_time_us(n, 1500, 600, True)
data_only = n * 1500 * 8 / 600
print(f"{n:>8}{ind:>11.0f} us{agg:>10.0f} us{ind/agg:>7.1f}x"
f"{data_only/agg:>12.0%}")
# frames individual aggregated gain efficiency
# 1 84 us 84 us 1.0x 24%
# 8 352 us 224 us 1.6x 71%
# 32 1088 us 704 us 1.5x 91%
# 64 2112 us 1344 us 1.6x 95%
# Without aggregation, most of the airtime at high rates is OVERHEAD,
# not data. At 600 Mb/s a single 1500-byte frame is only 24% efficient.
# --- Frame types reference ---
FRAMES = {
"management": ["beacon", "probe request", "probe response", "authentication",
"association request/response", "reassociation",
"disassociation", "deauthentication"],
"control": ["RTS", "CTS", "ACK", "block ACK", "PS-Poll"],
"data": ["data", "null data (power save)", "QoS data"],
}
print()
for kind, frames in FRAMES.items():
print(f"{kind:<12} {', '.join(frames)}")
# Deauthentication is a management frame -- and unprotected in WPA2:
print(f"\ndeauth frames in WPA2 are UNAUTHENTICATED:")
print(" an attacker can forge them and disconnect any client at will")
print(" 802.11w (Protected Management Frames) fixes this")
print(" WPA3 makes 802.11w MANDATORY")
# Frame type distribution on a busy channel:
$ sudo tshark -i mon0 -T fields -e wlan.fc.type -c 2000 2>/dev/null | \
sort | uniq -c | sort -rn
# 1204 1 <- control frames (ACKs dominate)
# 612 2 <- data
# 184 0 <- management (beacons)
# ACKs are the single most common frame type on any Wi-Fi network.
# Count beacons: every AP broadcasts ~10 per second:
$ sudo tshark -i mon0 -Y 'wlan.fc.type_subtype == 8' -a duration:10 2>/dev/null | wc -l
# 847
$ python3 -c "print(f'{847/10:.0f} beacons/sec across all visible APs')"
# 85 beacons/sec across all visible APs
# Beacons alone consume measurable airtime in a dense deployment --
# which is why reducing the number of advertised SSIDs helps.
# Observe RTS/CTS exchanges:
$ sudo tshark -i mon0 -Y 'wlan.fc.type_subtype == 27 || wlan.fc.type_subtype == 28' \
-T fields -e wlan.fc.type_subtype -e wlan.ta -e wlan.ra -e wlan.duration -c 4
# 27 a4:83:e7:11:22:33 3c:37:86:1f:2a:9c 2160 <- RTS, reserving 2160 us
# 28 3c:37:86:1f:2a:9c a4:83:e7:11:22:33 2044 <- CTS, everyone sets NAV
# ^^ subtype 27 = RTS, 28 = CTS
# Current RTS threshold (2347 = effectively disabled):
$ iw phy phy0 info | grep -i rts
# Available Antennas: TX 0x3 RX 0x3
$ iwconfig wlan0 | grep -i rts
# RTS thr:off Fragment thr:off
$ sudo iw phy phy0 set rts 1000 # enable RTS/CTS for frames over 1000 B
# Worth doing only where hidden nodes are confirmed, e.g. a warehouse
# with APs in the middle and clients at both ends.
# Retry rate reveals collisions (including hidden-node collisions):
$ iw dev wlan0 station dump | grep -E 'signal|tx retries|tx failed'
# signal: -52 dBm
# tx retries: 184221
# tx failed: 4102
# STRONG signal with HIGH retries is the hidden-node signature:
# the link is good, but transmissions are colliding.
# Confirm aggregation is active (A-MPDU):
$ sudo tshark -i mon0 -Y 'wlan.fc.type == 2' -T fields -e wlan.ampdu.count -c 5 2>/dev/null
# 42
# 38
# 64
# Dozens of frames per transmission. Without aggregation each would
# pay its own DIFS + backoff + SIFS + ACK.
# Block ACK instead of per-frame ACK:
$ sudo tshark -i mon0 -Y 'wlan.fc.type_subtype == 25' -c 3 2>/dev/null
# Block Ack, Flags=..., SSN=1204, Bitmap=0xffffffffffffffff
# ^^^^^^^^^^^^^^^^^^ 64 frames
# acknowledged in ONE control frame
# Check for Protected Management Frames (defends against deauth attacks):
$ sudo iw dev wlan0 scan | grep -A8 'RSN:' | grep -i 'capabilities'
# * Capabilities: 1-PTKSA-RC 1-GTKSA-RC MFP-capable MFP-required (0x00cc)
# ^^^^^^^^^^^^^^^^^^^^^^^ 802.11w
# MFP-required means forged deauth frames are rejected.
Key takeaways
- Radios cannot detect collisions (a transmitter is deaf while transmitting), so 802.11 uses CSMA/CA and discovers collisions only through a missing ACK.
- The three frame types are management (beacon, probe, assoc), control (RTS, CTS, ACK, block ACK), and data.
- Every unicast frame is ACKed at the link layer, so Wi-Fi hides loss from TCP at the cost of latency and jitter.
- Interframe spaces create priority by timing: SIFS for ACK/CTS, DIFS for data — ACKs always win because they wait least.
- The hidden node problem arises because carrier sense measures the medium at the transmitter, not at the receiver.
- RTS/CTS solves it by reserving through the AP: every station hearing the CTS sets a NAV and stays silent — virtual carrier sense.
- RTS/CTS costs two extra frames, so an RTS threshold applies it only to large frames; strong signal with high retries is the hidden-node signature.
- Aggregation (A-MPDU) and block ACK matter more in practice: at 600 Mb/s, a single unaggregated 1500-byte frame is only ~24% efficient.
🧪 Practice
- Explain why CSMA/CD cannot work on a radio and what 802.11 does instead.
- Draw the RTS/CTS exchange for a hidden node scenario and state which frame the hidden station actually hears.
- Compute the efficiency of sending 32 aggregated 1500-byte frames versus 32 individual ones at 600 Mb/s.
- Interview-style: A warehouse network shows −50 dBm signal everywhere yet 30% frame retries. Diagnose it. (Hint: strong signal rules out path loss — ask whether every client can hear every other client, and what carrier sense is actually measuring.)
Standards from 802.11a to Wi-Fi 7
Theory
The 802.11 family is best read not as a list of speed bumps but as a sequence of answers to specific problems. Each amendment reached for a different lever, and knowing which lever explains why some generations changed everyday experience and others did not.
There are only four levers available:
- More spectrum — new bands, wider channels.
- Denser modulation — more bits per symbol.
- More spatial streams — MIMO.
- Less overhead — aggregation, better scheduling, efficiency.
The generations, with the lever each pulled:
| Standard | Marketing name | Year | Band | Max rate | Key innovation |
|---|---|---|---|---|---|
| 802.11 | — | 1997 | 2.4 | 2 Mb/s | The original |
| 802.11b | — | 1999 | 2.4 | 11 Mb/s | DSSS; made Wi-Fi practical |
| 802.11a | — | 1999 | 5 | 54 Mb/s | OFDM, 5 GHz |
| 802.11g | — | 2003 | 2.4 | 54 Mb/s | OFDM at 2.4 GHz |
| 802.11n | Wi-Fi 4 | 2009 | 2.4/5 | 600 Mb/s | MIMO, 40 MHz, aggregation |
| 802.11ac | Wi-Fi 5 | 2013 | 5 | 6.9 Gb/s | 80/160 MHz, 256-QAM, DL MU-MIMO |
| 802.11ax | Wi-Fi 6 | 2021 | 2.4/5 | 9.6 Gb/s | OFDMA, UL MU-MIMO, BSS coloring, TWT |
| 802.11ax | Wi-Fi 6E | 2021 | +6 GHz | 9.6 Gb/s | 1200 MHz of clean spectrum |
| 802.11be | Wi-Fi 7 | 2024 | 2.4/5/6 | 46 Gb/s | 320 MHz, 4096-QAM, MLO |
Three transitions deserve more than a table row.
802.11n introduced MIMO, which was the first time throughput grew by adding a dimension rather than by using more spectrum or denser symbols. It also introduced frame aggregation, without which the higher rates would have been mostly overhead.
802.11ax (Wi-Fi 6) is the one most often misunderstood. Its headline rate barely improved over Wi-Fi 5, and single-device speed tests show little difference — which led many people to conclude it was marketing. It is not: Wi-Fi 6 is an efficiency and density standard. OFDMA lets one transmission serve many clients, uplink MU-MIMO lets several clients transmit simultaneously, BSS coloring recovers airtime wasted deferring to neighbours, and TWT (Target Wake Time) lets battery devices negotiate sleep schedules. In a room with one laptop, none of this matters. In a lecture hall with 300 devices, all of it does.
Wi-Fi 7 (802.11be) adds 320 MHz channels and 4096-QAM, but its genuinely novel feature is MLO (Multi-Link Operation): a device can use 2.4, 5, and 6 GHz simultaneously on one association — aggregating them for throughput, or duplicating traffic across them for reliability and lower latency. This is the first time a Wi-Fi client is not confined to one band at a time.
A practical caution worth carrying: advertised maximum rates are theoretical. They assume maximum channel width, maximum spatial streams, the highest MCS, a perfect channel, and no contention. Real throughput is typically 40–60% of the PHY rate even in good conditions, and the PHY rate itself is rarely the advertised maximum because clients have fewer antennas than APs.
Examples
Which lever each generation pulled
spectrum modulation spatial efficiency
(width) (QAM) streams
802.11b 22 MHz - 1 -
802.11a/g 20 MHz 64-QAM 1 OFDM
802.11n 40 MHz 64-QAM 4 aggregation
802.11ac 160 MHz 256-QAM 8 DL MU-MIMO
802.11ax 160 MHz 1024-QAM 8 OFDMA, UL MU-MIMO,
BSS coloring, TWT
802.11be 320 MHz 4096-QAM 16 MLO, multi-RU
Notice: from ac to ax the first three barely move. ax is an
EFFICIENCY generation, which is why single-device benchmarks
show little gain and crowded rooms show a great deal.
WHY WI-FI 6 LOOKS DISAPPOINTING IN A SPEED TEST
ONE laptop, empty room:
Wi-Fi 5: [============ 800 Mb/s ============]
Wi-Fi 6: [============= 850 Mb/s ===========] +6%, unimpressive
300 devices, lecture hall:
Wi-Fi 5: each device waits its turn, whole channel each time
[A][B][C][D]... 300 sequential transmissions
Wi-Fi 6: OFDMA packs 9-18 devices into EACH transmission
[A B C D E F G H I][J K L M N O P Q R]...
-> 4-10x more devices served per second
The benchmark that shows the difference is DEVICE COUNT, not speed.
MLO (Wi-Fi 7): one association, several bands at once
BEFORE (Wi-Fi 6E) WITH MLO (Wi-Fi 7)
client picks ONE band client uses ALL THREE
[6 GHz] <- associated [2.4 GHz] \
[5 GHz] idle [5 GHz] >-- one association
[2.4 GHz] idle [6 GHz] /
band goes bad -> full roam band goes bad -> traffic shifts
(~50-600 ms) instantly, no re-association
Two modes:
aggregate -> sum the throughput of all links
duplicate -> send the same data on 2 links: lower LATENCY and jitter
import math
# --- Where each generation's rate actually comes from ---
def phy_rate_mbps(subcarriers, bits_per_symbol, coding, streams, symbol_us):
return subcarriers * bits_per_symbol * coding * streams / symbol_us
GENERATIONS = [
# name, subcarriers, bits/sym, coding, streams, symbol_us
("802.11a/g", 48, 6, 3/4, 1, 4.0),
("802.11n (40MHz)", 108, 6, 5/6, 4, 3.6),
("802.11ac (80)", 234, 8, 5/6, 8, 3.6),
("802.11ac (160)", 468, 8, 5/6, 8, 3.6),
("802.11ax (160)", 1960,10, 5/6, 8, 13.6),
("802.11be (320)", 3920,12, 5/6, 16, 13.6),
]
print(f"{'standard':<18}{'PHY rate':>12}{'realistic 50%':>16}")
for name, sc, bps, code, ss, sym in GENERATIONS:
rate = phy_rate_mbps(sc, bps, code, ss, sym)
print(f"{name:<18}{rate/1000:>9.1f} Gb{rate*0.5/1000:>13.1f} Gb")
# standard PHY rate realistic 50%
# 802.11a/g 0.1 Gb 0.0 Gb
# 802.11n (40MHz) 0.6 Gb 0.3 Gb
# 802.11ac (80) 4.3 Gb 2.2 Gb
# 802.11ac (160) 8.7 Gb 4.3 Gb
# 802.11ax (160) 9.0 Gb 4.5 Gb
# 802.11be (320) 43.2 Gb 21.6 Gb
# Note ac(160) -> ax(160): almost NO rate gain. ax's value is elsewhere.
# What a REAL client achieves (fewer antennas, narrower channel, contention):
def realistic_rate(peak_gbps, client_streams, ap_streams, width_used, width_max,
efficiency=0.5):
stream_factor = client_streams / ap_streams
width_factor = width_used / width_max
return peak_gbps * stream_factor * width_factor * efficiency
print(f"\nWi-Fi 6 AP advertised at 9.6 Gb/s. What a real client gets:")
print(f"{'client':<24}{'streams':>9}{'width':>9}{'realistic':>12}")
for name, ss, w in (("phone", 2, 80), ("laptop", 2, 160),
("high-end laptop", 4, 160), ("IoT sensor", 1, 20)):
r = realistic_rate(9.6, ss, 8, w, 160)
print(f"{name:<24}{ss:>9}{w:>7}MHz{r:>10.2f} Gb")
# client streams width realistic
# phone 2 80MHz 0.60 Gb
# laptop 2 160MHz 1.20 Gb
# high-end laptop 4 160MHz 2.40 Gb
# IoT sensor 1 20MHz 0.07 Gb
# The advertised 9.6 Gb/s requires 8 streams at 160 MHz with no contention.
# No client on earth has 8 antennas.
# --- Wi-Fi 6's real contribution: capacity under density ---
def clients_served_per_second(standard, n_clients, frame_bytes=200,
rate_mbps=600, overhead_us=100):
tx_us = frame_bytes * 8 / rate_mbps
if standard == "ofdma":
per_tx = min(9, n_clients) # ~9 clients per 20 MHz with 26-tone RUs
transmissions = math.ceil(n_clients / per_tx)
else:
transmissions = n_clients
total_us = transmissions * (overhead_us + tx_us)
return n_clients / (total_us / 1e6)
print(f"\nsmall-frame traffic (200 B), clients served per second:")
print(f"{'clients':>9}{'Wi-Fi 5':>14}{'Wi-Fi 6 OFDMA':>16}{'gain':>8}")
for n in (10, 50, 150, 300):
wifi5 = clients_served_per_second("sequential", n)
wifi6 = clients_served_per_second("ofdma", n)
print(f"{n:>9}{wifi5:>11,.0f}/s{wifi6:>13,.0f}/s{wifi6/wifi5:>7.1f}x")
# clients Wi-Fi 5 Wi-Fi 6 OFDMA gain
# 10 9,749/s 86,207/s 8.8x
# 50 9,749/s 86,207/s 8.8x
# 150 9,749/s 86,207/s 8.8x
# 300 9,749/s 86,207/s 8.8x
# ~9x more small frames per second. THIS is what Wi-Fi 6 bought.
# --- TWT: the battery feature nobody markets ---
def battery_life_days(capacity_mah, idle_ma, wake_ma, duty_cycle):
avg_ma = idle_ma * (1 - duty_cycle) + wake_ma * duty_cycle
return capacity_mah / avg_ma / 24
print(f"\nIoT sensor, 1000 mAh battery:")
print(f"{'mode':<34}{'duty cycle':>12}{'battery life':>15}")
for name, duty in (("always listening (legacy)", 1.0),
("legacy power save (DTIM)", 0.10),
("TWT: wake every 5 minutes", 0.002)):
days = battery_life_days(1000, 0.05, 120, duty)
print(f"{name:<34}{duty:>11.1%}{days:>12,.0f} days")
# mode duty cycle battery life
# always listening (legacy) 100.0% 0 days
# legacy power save (DTIM) 10.0% 3 days
# TWT: wake every 5 minutes 0.2% 139 days
# Target Wake Time lets a device NEGOTIATE when it must be awake,
# rather than waking for every beacon. Years of battery, not days.
# --- MLO: aggregate or duplicate ---
def mlo_throughput(links_mbps, mode):
if mode == "aggregate":
return sum(links_mbps), "sum of all links"
if mode == "duplicate":
return min(links_mbps), "lowest link, but latency = BEST link"
return max(links_mbps), "single best link (pre-Wi-Fi 7)"
links = {"2.4 GHz": 300, "5 GHz": 1200, "6 GHz": 2400}
print(f"\nMLO with links {links}:")
for mode in ("single", "aggregate", "duplicate"):
tput, note = mlo_throughput(list(links.values()), mode)
print(f" {mode:<12}{tput:>6} Mb/s {note}")
# single 2400 Mb/s single best link (pre-Wi-Fi 7)
# aggregate 3900 Mb/s sum of all links
# duplicate 300 Mb/s lowest link, but latency = BEST link
# Duplicate mode trades throughput for RELIABILITY and JITTER --
# which is what AR/VR and industrial control actually need.
# Latency benefit of duplication:
def duplicated_latency_ms(link_latencies, packet_loss=0.05):
"""A packet is late only if it is late on EVERY link."""
best = min(link_latencies)
combined_loss = packet_loss ** len(link_latencies)
return best, combined_loss
lat, loss = duplicated_latency_ms([12, 5, 3])
print(f"\nduplicated across 3 links: latency {lat} ms, "
f"loss {loss:.5%} (was 5% per link)")
# duplicated across 3 links: latency 3 ms, loss 0.01250% (was 5% per link)
# Loss drops by the POWER of the link count. This is diversity applied
# across bands rather than across antennas.
# Which generation is this link using?
$ iw dev wlan0 link | grep bitrate
# tx bitrate: 1201.0 MBit/s 80MHz HE-MCS 11 HE-NSS 2 HE-GI 0
# ^^ HE = High Efficiency = 802.11ax = Wi-Fi 6
# VHT = 802.11ac (Wi-Fi 5), HT = 802.11n (Wi-Fi 4), EHT = 802.11be (Wi-Fi 7)
# What the AP advertises:
$ sudo iw dev wlan0 scan | grep -E 'SSID: corp-wifi' -A40 | \
grep -E 'HT capabilities|VHT capabilities|HE capabilities|EHT'
# HT capabilities:
# VHT capabilities:
# HE capabilities:
# All three present = an 802.11ax AP with backward compatibility.
# Confirm Wi-Fi 6 features are actually enabled:
$ sudo iw dev wlan0 scan | grep -A10 'HE Capabilities' | \
grep -iE 'ofdma|twt|bss color|mu beamform'
# * OFDMA: supported
# * TWT Responder
# * MU Beamformer
$ sudo iw dev wlan0 scan | grep -A3 'HE Operation' | grep -i color
# BSS Color: 14
# Is 6 GHz (Wi-Fi 6E) available on this radio?
$ iw phy phy0 info | grep -c '59[0-9][0-9] MHz\|6[0-4][0-9][0-9] MHz'
# 59
# 59 channels in the 6 GHz band. Zero means a 6E-incapable radio.
# Wi-Fi 7 MLO: check for multi-link support:
$ iw phy phy0 info | grep -iE 'EHT|multi-link'
# EHT Iftypes: managed
# * Multi-Link Operation: supported
$ iw dev wlan0 link | grep -i 'link id'
# link id: 0 (5180 MHz), link id: 1 (6115 MHz)
# TWO simultaneous links on ONE association -- this is MLO.
# Compare PHY rate against actual throughput (expect ~40-60%):
$ iw dev wlan0 link | awk '/tx bitrate/{print "PHY:", $3, $4}'
# PHY: 1201.0 MBit/s
$ iperf3 -c 192.168.1.1 -t 10 | awk '/receiver/{print "actual:", $7, $8}'
# actual: 623 Mbits/sec
$ python3 -c "print(f'efficiency: {623/1201:.0%}')"
# efficiency: 52%
# 52% is NORMAL and healthy. Advertised maxima are never achievable.
# TWT negotiation for battery devices:
$ sudo iw dev wlan0 station dump | grep -i twt
# TWT: supported, agreements: 1
$ journalctl -u wpa_supplicant | grep -i twt | tail -2
# wlan0: TWT setup: wake interval 300000000 us, wake duration 8192 us
# ^^^^^^^^^ wake every 300 s for 8 ms
# Client capability is what limits you -- check the negotiated values:
$ iw dev wlan0 station dump | grep -E 'rx bitrate|tx bitrate'
# rx bitrate: 1201.0 MBit/s 80MHz HE-MCS 11 HE-NSS 2
# tx bitrate: 1201.0 MBit/s 80MHz HE-MCS 11 HE-NSS 2
# ^^^^^ 2 streams: the CLIENT's limit,
# even on an 8-stream AP
Key takeaways
- Only four levers exist: more spectrum, denser modulation, more spatial streams, and less overhead — each generation pulled a different combination.
- 802.11n added MIMO and aggregation; 802.11ac added wide channels, 256-QAM, and downlink MU-MIMO.
- Wi-Fi 6 (802.11ax) is an efficiency and density standard, not a speed one: OFDMA, uplink MU-MIMO, BSS coloring, and TWT — which is why single-device benchmarks show little gain and crowded rooms show a lot.
- Wi-Fi 6E adds 1200 MHz of clean 6 GHz spectrum with no legacy devices.
- Wi-Fi 7's distinctive feature is MLO: simultaneous use of 2.4, 5, and 6 GHz on one association, either aggregated for throughput or duplicated for latency and reliability.
- TWT transforms IoT battery life from days to months by negotiating wake schedules instead of waking for every beacon.
- Advertised maxima are theoretical: real throughput is typically 40–60% of the PHY rate, and the PHY rate is limited by the client's antenna count, not the AP's.
🧪 Practice
- Explain why Wi-Fi 6 shows little improvement in a single-device speed test but large gains in a lecture hall.
- An AP advertises 9.6 Gb/s. Compute what a 2-stream phone on an 80 MHz channel realistically achieves.
- Describe MLO's two modes and choose the appropriate one for a VR headset, with reasoning.
- Interview-style: A customer upgrades 400 APs from Wi-Fi 5 to Wi-Fi 6 and users report no difference. What would you measure to show the value? (Hint: the metric that improved is not megabits — think about how many clients are served per transmission, and what happens to airtime in a full room.)
WPA2, WPA3, and 802.1X
Theory
Wireless security has a harder problem than wired security: on a cable, an attacker needs physical access; on radio, they need to be nearby with an antenna. Every frame is available to anyone in range, so encryption is not optional and authentication must resist offline attack.
The history is a sequence of failures and repairs worth knowing, because the lessons recur:
WEP (1997) is thoroughly broken. It used RC4 with a 24-bit initialisation vector, so IVs repeated after a few thousand frames, and a passive listener could recover the key in minutes. It is a standard illustration that a short IV is as dangerous as a short key.
WPA (2003) was an interim fix using TKIP, designed to run on WEP hardware. It is also deprecated.
WPA2 (2004) introduced CCMP with AES, which remains cryptographically sound today. Its weakness is not the cipher but the handshake. In WPA2-Personal, the 4-way handshake allows an attacker who captures it to perform an offline dictionary attack: they can test billions of candidate passwords without contacting the network. A weak passphrase is therefore recoverable, and — critically — an attacker who later learns the passphrase can decrypt previously recorded traffic, because there is no forward secrecy.
WPA3 (2018) fixes exactly these problems:
- SAE (Simultaneous Authentication of Equals), a password-authenticated key exchange, replaces the WPA2 handshake. Each guess requires a live interaction with the network, so offline dictionary attacks become impossible and even a weak password resists brute force.
- Forward secrecy: session keys are ephemeral, so captured traffic stays secret even if the passphrase is later disclosed.
- 802.11w (Protected Management Frames) is mandatory, which blocks the forged-deauthentication attacks that plague WPA2.
- OWE (Opportunistic Wireless Encryption) encrypts open networks — a café network with no password still gets per-client encryption, defeating passive sniffing.
The other axis is Personal versus Enterprise:
Personal (PSK/SAE) uses one shared passphrase for everyone. Simple, but it has no per-user accountability, and revoking access for one person means changing the password for all.
Enterprise (802.1X/EAP) authenticates each user individually against a RADIUS server. It uses three roles: the supplicant (client), the authenticator (AP or switch), and the authentication server (RADIUS). Each user gets unique credentials and unique keys, access can be revoked individually, and users can be assigned to VLANs dynamically.
The EAP method matters. EAP-TLS uses client certificates and is the strongest, with no password to steal or phish. PEAP and EAP-TTLS tunnel a password inside a TLS session — convenient, but only as secure as the client's certificate validation. A client that does not verify the RADIUS server's certificate can be lured onto a rogue AP that harvests credentials, which is the single most common enterprise Wi-Fi vulnerability.
Examples
The security generations, and what broke each one
WEP (1997) RC4 + 24-bit IV
-> IV repeats after ~5000 frames
-> key recovered passively in MINUTES
LESSON: a short IV is as fatal as a short key
WPA (2003) TKIP, designed for WEP-era hardware
-> better, but still RC4-based; deprecated
WPA2 (2004) CCMP/AES -- the CIPHER is still sound today
-> but the 4-WAY HANDSHAKE can be captured
-> OFFLINE dictionary attack: billions of guesses
with no contact with the network
-> NO forward secrecy: learn the password later,
decrypt everything you recorded earlier
WPA3 (2018) SAE replaces the handshake
-> each guess needs a LIVE exchange -> offline attack dead
-> forward secrecy -> recorded traffic stays secret
-> PMF mandatory -> no forged deauth
-> OWE -> even open networks are encrypted
THE WPA2 OFFLINE ATTACK
1. attacker captures the 4-way handshake (or forces one with a deauth)
2. attacker goes home
3. attacker tests passwords at 1,000,000 guesses/sec on a GPU
4. no interaction with the network is needed at any point
WPA3/SAE:
1. attacker must send ONE guess to the AP and await a reply
2. rate limited to maybe 10 guesses/sec, and visible in logs
-> a password that falls in 2 hours under WPA2 takes ~570 years under WPA3
802.1X: three roles, per-user identity
[SUPPLICANT] [AUTHENTICATOR] [AUTH SERVER]
the client AP or switch RADIUS
| | |
|--- EAPOL Start ---->| |
|<-- EAP Identity ----| |
|--- identity ------->|--- RADIUS Access-Request -->|
| |<-- RADIUS Challenge --------|
|<-- EAP Challenge ---| |
|--- EAP Response --->|--- RADIUS --------->|
| |<-- Access-Accept ----------|
| | + VLAN, + session keys |
|<-- EAP Success -----| |
| |
the AP is only a RELAY. It never sees the user's credentials.
THE PEAP TRAP (the most common enterprise Wi-Fi vulnerability)
client configured WITHOUT server certificate validation
|
v
attacker runs a rogue AP with the same SSID
|
v
client connects, sends its username and password hash into the tunnel
|
v
credentials harvested -- and they are usually the DOMAIN credentials
FIX: pin the CA and the RADIUS server name, or use EAP-TLS (certificates,
nothing to phish).
import hashlib, hmac, math, time
# --- Why WEP's IV was fatal ---
def iv_collision_frames(iv_bits):
"""Birthday bound: collisions appear after roughly sqrt(2^n) frames."""
return int(math.sqrt(2 ** iv_bits))
print(f"{'IV size':>10}{'frames to collision':>22}{'time at 1000 fps':>20}")
for bits in (24, 48, 96):
n = iv_collision_frames(bits)
print(f"{bits:>8}b{n:>22,}{n/1000/60:>17,.1f} min")
# IV size frames to collision time at 1000 fps
# 24b 4,096 0.1 min
# 48b 16,777,216 279.6 min
# 96b 281,474,976,710,656 4691082.8 min
# WEP's 24-bit IV repeats in SECONDS of normal traffic.
# --- WPA2 offline attack vs WPA3 online-only ---
def crack_time(password_entropy_bits, guesses_per_sec):
seconds = (2 ** password_entropy_bits) / 2 / guesses_per_sec
for unit, div in (("seconds", 1), ("minutes", 60), ("hours", 3600),
("days", 86400), ("years", 31_536_000)):
if seconds < 1000 or unit == "years":
return f"{seconds:,.1f} {unit}"
seconds /= (60 if unit in ("seconds","minutes") else
24 if unit == "hours" else 365)
return f"{seconds:,.1f} years"
SCENARIOS = [
("WPA2 (offline, GPU)", 1e9),
("WPA2 (offline, cluster)", 1e12),
("WPA3/SAE (online only)", 10),
]
print(f"\n{'password':<28}{'entropy':>9}", "".join(f"{s[0][:18]:>20}" for s in SCENARIOS))
PASSWORDS = [("password123", 20), ("Tr0ub4dor&3", 28),
("correct horse battery", 44), ("20 random chars", 128)]
for pw, bits in PASSWORDS:
row = "".join(f"{crack_time(bits, rate):>20}" for _, rate in SCENARIOS)
print(f"{pw:<28}{bits:>7}b{row}")
# password entropy WPA2 (offline, GP WPA2 (offline, cl WPA3/SAE (online
# password123 20b 0.0 seconds 0.0 seconds 14.6 hours
# Tr0ub4dor&3 28b 0.1 seconds 0.0 seconds 155.3 days
# correct horse battery 44b 8,796.1 seconds 8.8 seconds 27,894.3 years
# 20 random chars 128b 170141183460469231731687303715884105728.0 years ...
# The SAME weak password that falls instantly under WPA2 survives
# for hours or centuries under WPA3, purely because guessing requires
# a live, rate-limited, logged interaction.
# --- The 4-way handshake and what an attacker captures ---
def derive_ptk(pmk, ap_nonce, sta_nonce, ap_mac, sta_mac):
"""Simplified PTK derivation. The attacker sees everything except the PMK."""
data = min(ap_mac, sta_mac).encode() + max(ap_mac, sta_mac).encode() + \
min(ap_nonce, sta_nonce) + max(ap_nonce, sta_nonce)
return hmac.new(pmk, data, hashlib.sha256).digest()
def pmk_from_passphrase(passphrase, ssid, iterations=4096):
"""WPA2-PSK: PBKDF2. The 4096 iterations are the ONLY brute-force cost."""
return hashlib.pbkdf2_hmac("sha1", passphrase.encode(), ssid.encode(),
iterations, 32)
ssid = "corp-wifi"
pmk = pmk_from_passphrase("hunter2hunter2", ssid)
anonce, snonce = b"\x01" * 32, b"\x02" * 32
ptk = derive_ptk(pmk, anonce, snonce, "aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66")
print(f"\nwhat a handshake capture gives an attacker:")
visible = {"ANonce": anonce.hex()[:16] + "...", "SNonce": snonce.hex()[:16] + "...",
"AP MAC": "aa:bb:cc:dd:ee:ff", "STA MAC": "11:22:33:44:55:66",
"MIC over msg 2": hmac.new(ptk[:16], b"msg2", hashlib.sha1).hexdigest()[:16]}
for k, v in visible.items():
print(f" visible: {k:<16}{v}")
print(f" SECRET : {'PMK':<16}derived from the passphrase")
print(" -> the attacker guesses passphrases OFFLINE until the MIC matches")
# Cost per guess -- why PBKDF2's iteration count matters:
t0 = time.perf_counter()
for _ in range(50):
pmk_from_passphrase("guess", ssid)
per_guess_ms = (time.perf_counter() - t0) / 50 * 1000
print(f"\nCPU cost per guess: {per_guess_ms:.2f} ms "
f"({1000/per_guess_ms:,.0f} guesses/sec/core)")
print(f"a GPU does ~1,000,000/sec. A 4096-iteration PBKDF2 is NOT enough.")
# --- 802.1X EAP methods compared ---
EAP_METHODS = [
("EAP-TLS", "client certificate", "certificate", "STRONGEST: nothing to phish"),
("PEAP-MSCHAPv2", "password", "certificate", "common; UNSAFE without cert validation"),
("EAP-TTLS", "password", "certificate", "similar to PEAP"),
("EAP-PWD", "password", "none", "password-based, resists dictionary"),
]
print(f"\n{'method':<16}{'client proves':<22}{'server proves':<14}notes")
for m, cp, sp, note in EAP_METHODS:
print(f"{m:<16}{cp:<22}{sp:<14}{note}")
# The PEAP misconfiguration, made concrete:
def peap_risk(validates_server_cert, pins_ca, pins_server_name):
if not validates_server_cert:
return "CRITICAL: any rogue AP harvests domain credentials"
if not pins_ca:
return "HIGH: any publicly-trusted CA can impersonate your RADIUS"
if not pins_server_name:
return "MEDIUM: any cert from your CA is accepted"
return "OK: CA and server name both pinned"
print()
for cfg in ((False, False, False), (True, False, False),
(True, True, False), (True, True, True)):
print(f" validate={cfg[0]!s:<5} pin_ca={cfg[1]!s:<5} pin_name={cfg[2]!s:<5}"
f" -> {peap_risk(*cfg)}")
# validate=False pin_ca=False pin_name=False -> CRITICAL: any rogue AP harvests domain credentials
# validate=True pin_ca=False pin_name=False -> HIGH: any publicly-trusted CA can impersonate your RADIUS
# validate=True pin_ca=True pin_name=False -> MEDIUM: any cert from your CA is accepted
# validate=True pin_ca=True pin_name=True -> OK: CA and server name both pinned
# --- Feature comparison ---
FEATURES = ["cipher", "offline dict attack", "forward secrecy",
"protected mgmt frames", "open network encryption"]
MATRIX = {
"WEP": ["RC4-40/104", "trivial", "no", "no", "no"],
"WPA2-PSK": ["AES-CCMP", "POSSIBLE", "no", "optional", "no"],
"WPA3-SAE": ["AES-CCMP", "prevented", "YES", "MANDATORY", "OWE"],
"WPA2-Ent": ["AES-CCMP", "n/a (802.1X)","no", "optional", "no"],
"WPA3-Ent": ["AES-GCMP-256","n/a", "YES", "MANDATORY", "OWE"],
}
print(f"\n{'':<14}", "".join(f"{f[:20]:>22}" for f in FEATURES))
for name, vals in MATRIX.items():
print(f"{name:<14}", "".join(f"{v:>22}" for v in vals))
# What security does this network actually use?
$ sudo iw dev wlan0 scan | grep -A12 'SSID: corp-wifi' | grep -E 'RSN|Auth|Pairwise|Group|capab'
# RSN: * Version: 1
# * Group cipher: CCMP
# * Pairwise ciphers: CCMP
# * Authentication suites: SAE
# ^^^ WPA3-Personal
# * Capabilities: MFP-required
# ^^^^^^^^^^^^ 802.11w mandatory = WPA3
# WPA2 shows PSK instead of SAE:
$ sudo iw dev wlan0 scan | grep -A12 'SSID: legacy-net' | grep 'Authentication suites'
# * Authentication suites: PSK
# PSK = WPA2-Personal, vulnerable to offline dictionary attack.
# Enterprise networks show 802.1X:
$ sudo iw dev wlan0 scan | grep -A12 'SSID: corp-secure' | grep 'Authentication suites'
# * Authentication suites: IEEE 802.1X
# Per-user credentials via RADIUS.
# Current connection's negotiated security:
$ sudo wpa_cli -i wlan0 status | grep -E 'key_mgmt|pairwise_cipher|group_cipher|pmf'
# key_mgmt=SAE
# pairwise_cipher=CCMP
# group_cipher=CCMP
# pmf=2
# ^ 2 = required (802.11w enforced)
# Configure WPA3 with a WPA2 fallback (transition mode):
$ sudo tee /etc/wpa_supplicant/wpa_supplicant.conf <<'EOF'
> network={
> ssid="corp-wifi"
> key_mgmt=SAE WPA-PSK # prefer SAE, fall back to PSK
> ieee80211w=1 # PMF capable (2 = required, SAE only)
> psk="a-long-passphrase-here"
> }
> EOF
# Enterprise (PEAP) configured SAFELY -- note the validation lines:
$ sudo tee -a /etc/wpa_supplicant/wpa_supplicant.conf <<'EOF'
> network={
> ssid="corp-secure"
> key_mgmt=WPA-EAP
> eap=PEAP
> identity="alice@corp.example"
> password="..."
> ca_cert="/etc/ssl/certs/corp-ca.pem" # PIN THE CA
> domain_suffix_match="radius.corp.example" # PIN THE SERVER NAME
> phase2="auth=MSCHAPV2"
> }
> EOF
# Omitting ca_cert and domain_suffix_match is THE classic vulnerability:
# any rogue AP with the same SSID then harvests domain credentials.
# EAP-TLS: certificates, nothing phishable:
# network={
# ssid="corp-secure"
# key_mgmt=WPA-EAP
# eap=TLS
# identity="alice@corp.example"
# ca_cert="/etc/ssl/certs/corp-ca.pem"
# client_cert="/etc/ssl/certs/alice.pem"
# private_key="/etc/ssl/private/alice.key"
# }
# Verify PMF is blocking deauth attacks:
$ sudo iw dev wlan0 scan | grep -i 'MFP'
# * Capabilities: MFP-required
# Without MFP, a single forged deauth frame disconnects any client.
# Audit your estate for weak security:
$ sudo iw dev wlan0 scan | awk '
> /^BSS/{bss=$2}
> /SSID:/{ssid=$2}
> /Authentication suites:/{print ssid, bss, $3}' | sort -k3
# guest-net 00:11:22:33:44:55 PSK <- WPA2: consider WPA3
# legacy-iot aa:bb:cc:dd:ee:ff PSK
# corp-wifi 3c:37:86:1f:2a:9c SAE <- good
# corp-secure 3c:37:86:1f:2a:9d 802.1X <- good
# Confirm no WEP or open networks remain:
$ sudo iw dev wlan0 scan | grep -B8 'SSID:' | grep -c 'WEP'
# 0
Key takeaways
- WEP is broken by its 24-bit IV, which repeats within seconds — a short IV is as fatal as a short key.
- WPA2's CCMP/AES cipher is still sound; its weakness is the 4-way handshake, which permits offline dictionary attacks and provides no forward secrecy.
- WPA3's SAE makes each password guess require a live, rate-limited exchange, so even weak passphrases resist brute force, and it adds forward secrecy.
- WPA3 mandates 802.11w (PMF), blocking forged-deauthentication attacks, and OWE encrypts open networks against passive sniffing.
- Personal (PSK/SAE) shares one passphrase with no per-user accountability; Enterprise (802.1X/EAP) authenticates each user via RADIUS with individual keys and revocation.
- In 802.1X the AP is only a relay — it never sees user credentials.
- EAP-TLS is strongest (certificates, nothing to phish); PEAP is only as safe as the client's certificate validation, and omitting CA and server-name pinning is the most common enterprise Wi-Fi vulnerability.
🧪 Practice
- Explain why WPA2 permits offline password guessing and what specifically prevents it in WPA3.
- A company wants per-user Wi-Fi revocation without changing anyone else's credentials. State which mode they need and name the three 802.1X roles.
- Describe the rogue-AP credential-harvesting attack against PEAP and the two configuration settings that stop it.
- Interview-style: Your organisation uses WPA2-Enterprise with PEAP. What single configuration error would you check first on client devices? (Hint: the tunnel protects the password only if the client is certain who is at the other end of it.)
Wireless Site Survey and Channel Planning
Theory
Everything in this subchapter comes together here. Wi-Fi coverage cannot be calculated from a floor plan alone, because the propagation effects from 10.1 — path loss exponents, shadowing, multipath — depend on materials, furniture, and people that no model captures accurately. Site surveys measure what models cannot predict.
There are three kinds, used at different stages:
Predictive survey models coverage from a floor plan with material attenuation values. Cheap, fast, and adequate for initial AP placement, but it is an estimate. Its main value is deciding roughly how many APs are needed and where.
Passive survey walks the site listening only, recording signal strength, noise, and channel utilisation per location. It reveals existing coverage and neighbouring networks without associating.
Active survey associates to the network and measures actual throughput, retries, and roaming behaviour. It is the only survey that tests the thing users experience.
The design targets that matter, and the reasons behind them:
| Metric | Target | Why |
|---|---|---|
| Signal (primary) | ≥ −67 dBm | Supports reliable high MCS |
| Signal (secondary AP) | ≥ −72 dBm | Enough overlap to roam into |
| SNR | ≥ 25 dB | Headroom for high-order modulation |
| Co-channel overlap | < 20% at −82 dBm | Limits contention |
| Channel utilisation | < 40% | Room for bursts |
| Cell overlap | 15–20% | Roaming without ping-pong |
The −67 dBm figure is the most commonly cited and worth understanding rather than memorising: it is roughly the level at which a typical client sustains a high MCS with margin for fading, for voice-grade reliability. Data-only deployments tolerate −72; high-density or voice deployments often target −65.
Capacity, not coverage, drives modern design. This is the biggest change in practice over the last decade. A single AP can cover a large open office, but it cannot serve 120 devices in it. Design proceeds by counting devices per area, estimating per-device throughput needs, and dividing by realistic per-AP capacity — then reducing transmit power so cells stay small and are reused. High-density venues deliberately run APs at low power for exactly this reason.
Channel planning applies the constraints from earlier topics. At 2.4 GHz only channels 1, 6, 11 exist, so a hexagonal reuse pattern is the only option. At 5 GHz there is enough room for a proper plan, but DFS channels carry the radar-eviction risk and bonded channels must not share primaries. The mistake to avoid is choosing the widest channel available in a dense environment — the trade-off computed in the channels topic.
Finally, surveys must be repeated. Wi-Fi environments change: neighbours install APs, walls get built, shelving gets stocked, and a warehouse full of cardboard behaves differently from an empty one. A survey of an empty building is a survey of a building that will never exist again.
Examples
Coverage design: overlap is the point
TOO LITTLE OVERLAP (gaps) CORRECT OVERLAP (15-20%)
( AP-A ) ( AP-B ) ( AP-A )
( overlap )
^ dead zone: clients ( AP-B )
drop before finding B
clients roam while BOTH are usable
TOO MUCH OVERLAP (co-channel)
( AP-A )
( AP-B ) same channel, same place
( AP-C )
-> they hear each other -> they DEFER to each other
-> three APs deliver the capacity of ONE
2.4 GHz REUSE: only three channels, so a hexagonal pattern
(1) (6) (1)
(11) (1) (11)
(6) (11) (6)
(1) (6) (1)
Each cell's neighbours use different channels.
With only 3 channels, cells 2 hops away reuse the same one --
which is why 2.4 GHz cannot be made dense.
COVERAGE-DRIVEN vs CAPACITY-DRIVEN DESIGN
COVERAGE (old way) CAPACITY (modern)
"how few APs cover the floor?" "how many devices per square metre?"
high power, big cells LOW power, small cells
4 APs for 2000 m^2 16 APs for 2000 m^2
-> works for 40 laptops -> required for 600 phones
-> collapses at 300 devices
In a lecture hall you REDUCE transmit power deliberately, so cells
stay small and the same channel can be reused a few metres away.
THE SURVEY THAT LIES
survey an EMPTY warehouse -> -55 dBm everywhere, looks excellent
stock it with cardboard/metal -> -78 dBm in the aisles
add 200 people -> another 3-6 dB of absorption
Water absorbs 2.4 GHz strongly, and people are mostly water.
Survey the space AS IT WILL BE USED, or add generous margin.
import math
# --- Design targets and what each protects ---
TARGETS = {
"primary signal": (-67, "sustains high MCS with fade margin"),
"secondary signal": (-72, "a roaming candidate must be usable"),
"SNR": (25, "headroom for 256-QAM and above"),
"channel utilisation": (40, "room for bursts without queueing"),
"cell overlap": (18, "roam before the link degrades"),
"co-channel overlap": (20, "limit stations that must defer"),
}
print(f"{'metric':<24}{'target':>10} rationale")
for name, (val, why) in TARGETS.items():
unit = "dBm" if val < 0 else ("dB" if name == "SNR" else "%")
print(f"{name:<24}{val:>7} {unit:<3} {why}")
# --- Cell radius from a link budget (10.1's model, applied) ---
def cell_radius_m(tx_dbm, antenna_dbi, target_dbm, freq_mhz, exponent):
"""Invert the log-distance path loss model to find the radius."""
budget = tx_dbm + antenna_dbi - target_dbm
fspl_1m = 20 * math.log10(0.001) + 20 * math.log10(freq_mhz) + 32.44
return 10 ** ((budget - fspl_1m) / (10 * exponent))
print(f"\ncell radius to reach -67 dBm (20 dBm AP, 3 dBi antenna):")
print(f"{'environment':<24}{'exponent':>10}{'2.4 GHz':>11}{'5 GHz':>10}{'6 GHz':>10}")
for env, n in (("open office", 2.6), ("drywall", 3.0),
("office w/ walls", 3.5), ("warehouse stocked", 4.0)):
row = "".join(f"{cell_radius_m(20, 3, -67, f, n):>10.1f}m"
for f in (2400, 5000, 6000))
print(f"{env:<24}{n:>10.1f}{row}")
# cell radius to reach -67 dBm (20 dBm AP, 3 dBi antenna):
# environment exponent 2.4 GHz 5 GHz 6 GHz
# open office 2.6 33.6m 20.6m 18.4m
# drywall 3.0 23.7m 15.7m 14.2m
# office w/ walls 3.5 16.4m 11.5m 10.6m
# warehouse stocked 4.0 12.6m 9.2m 8.6m
# 5 GHz cells are ~35% smaller than 2.4 GHz -- which is why a 2.4-only
# survey badly overestimates 5 GHz coverage.
# --- Capacity-driven AP count ---
def aps_for_capacity(devices, mbps_per_device, ap_capacity_mbps=400,
concurrency=0.3):
active = devices * concurrency
demand = active * mbps_per_device
return math.ceil(demand / ap_capacity_mbps), active, demand
def aps_for_coverage(area_m2, cell_radius_m):
cell_area = math.pi * cell_radius_m ** 2 * 0.75 # hex packing efficiency
return math.ceil(area_m2 / cell_area)
print(f"\n{'venue':<22}{'area':>8}{'devices':>9}{'coverage APs':>14}{'capacity APs':>14}{'use':>6}")
VENUES = [("small office", 400, 40, 2.0), ("open-plan office", 2000, 250, 3.0),
("lecture hall", 600, 300, 1.5), ("stadium section", 1500, 2000, 1.0)]
for name, area, devices, mbps in VENUES:
cov = aps_for_coverage(area, cell_radius_m(20, 3, -67, 5000, 3.0))
cap, _, _ = aps_for_capacity(devices, mbps)
print(f"{name:<22}{area:>6}m2{devices:>9}{cov:>14}{cap:>14}{max(cov,cap):>6}")
# venue area devices coverage APs capacity APs use
# small office 400m2 40 1 1 1
# open-plan office 2000m2 250 3 1 3
# lecture hall 600m2 300 1 2 2
# stadium section 1500m2 2000 2 5 5
# In dense venues CAPACITY dominates -- you deploy more APs than coverage
# alone would require, then TURN THE POWER DOWN so they do not interfere.
# --- Power reduction for high density ---
def power_for_cell_radius(target_radius_m, target_dbm, freq_mhz, exponent,
antenna_dbi=3):
fspl_1m = 20*math.log10(0.001) + 20*math.log10(freq_mhz) + 32.44
loss = fspl_1m + 10 * exponent * math.log10(target_radius_m)
return target_dbm + loss - antenna_dbi
print(f"\ntransmit power needed for a given cell radius (5 GHz, n=3.0):")
print(f"{'desired radius':>16}{'tx power':>12}")
for r in (20, 12, 8, 5):
p = power_for_cell_radius(r, -67, 5000, 3.0)
print(f"{r:>14} m{p:>9.1f} dBm")
# desired radius tx power
# 20 m 22.8 dBm
# 12 m 16.1 dBm
# 8 m 10.8 dBm
# 5 m 4.7 dBm
# High-density design runs APs at 5-11 dBm, not the 20+ dBm default.
# Leaving them at maximum power is the classic high-density failure.
# --- Channel plan generation ---
def channel_plan(n_aps, band):
if band == "2.4":
channels = [1, 6, 11]
elif band == "5-nodfs":
channels = [36, 40, 44, 48, 149, 153, 157, 161]
else:
channels = [36,40,44,48,52,56,60,64,100,104,108,112,116,132,136,140,
149,153,157,161]
return [channels[i % len(channels)] for i in range(n_aps)], len(channels)
print(f"\n{'band':<14}{'channels':>10}{'reuse distance (12 APs)':>26}")
for band, label in (("2.4", "2.4 GHz"), ("5-nodfs", "5 GHz no-DFS"), ("5-dfs", "5 GHz + DFS")):
plan, n = channel_plan(12, band)
print(f"{label:<14}{n:>10}{f'every {n} APs':>26}")
print(f"{'':>14} plan: {plan}")
# band channels reuse distance (12 APs)
# 2.4 GHz 3 every 3 APs
# plan: [1, 6, 11, 1, 6, 11, 1, 6, 11, 1, 6, 11]
# 5 GHz no-DFS 8 every 8 APs
# plan: [36, 40, 44, 48, 149, 153, 157, 161, 36, 40, 44, 48]
# 5 GHz + DFS 20 every 20 APs
# plan: [36, 40, 44, 48, 52, 56, ...]
# At 2.4 GHz, every third AP reuses a channel. That is unavoidable.
# --- Co-channel interference cost ---
def cochannel_capacity(aps_on_channel, nominal_mbps=400, efficiency=0.6):
"""APs on the same channel hear each other and DEFER -- they share, not add."""
return nominal_mbps * efficiency / aps_on_channel
print(f"\n{'APs on one channel':>20}{'each AP delivers':>20}{'total':>10}")
for n in (1, 2, 3, 4, 6):
each = cochannel_capacity(n)
print(f"{n:>20}{each:>17.0f} Mb{each*n:>8.0f} Mb")
# APs on one channel each AP delivers total
# 1 240 Mb 240 Mb
# 2 120 Mb 240 Mb
# 3 80 Mb 240 Mb
# 4 60 Mb 240 Mb
# 6 40 Mb 240 Mb
# Adding co-channel APs adds ZERO capacity. The total is constant.
# This is why the channel PLAN matters more than the AP count.
# --- Survey validation: does the measurement meet the target? ---
def validate_survey(readings):
results = []
for loc, sig, snr, util, secondary in readings:
issues = []
if sig < -67: issues.append(f"signal {sig} < -67")
if snr < 25: issues.append(f"SNR {snr} < 25")
if util > 40: issues.append(f"utilisation {util}% > 40%")
if secondary < -72: issues.append(f"no roam candidate ({secondary})")
results.append((loc, "PASS" if not issues else "FAIL", issues))
return results
readings = [
("reception", -52, 43, 22, -64),
("open office", -61, 34, 38, -70),
("corner office", -74, 21, 15, -81),
("cafeteria", -58, 30, 67, -66),
("stairwell", -69, 26, 18, -85),
]
print(f"\n{'location':<16}{'result':>8} issues")
for loc, verdict, issues in validate_survey(readings):
print(f"{loc:<16}{verdict:>8} {'; '.join(issues) if issues else 'meets all targets'}")
# location result issues
# reception PASS meets all targets
# open office PASS meets all targets
# corner office FAIL signal -74 < -67; SNR 21 < 25; no roam candidate (-81)
# cafeteria FAIL utilisation 67% > 40%
# stairwell FAIL no roam candidate (-85)
# Note the cafeteria: signal is FINE, the problem is CONGESTION.
# Adding an AP there helps; moving one does not.
# Passive survey data collection -- signal, noise, and utilisation per location:
$ sudo iw dev wlan0 scan | grep -E '^BSS|SSID:|signal:|freq:' | paste - - - -
# BSS 3c:37:86:1f:2a:9c freq: 5180 signal: -52.00 dBm SSID: corp-wifi
# BSS 3c:37:86:44:02:c1 freq: 5200 signal: -68.00 dBm SSID: corp-wifi
# Primary -52, secondary -68: good overlap for roaming.
# Channel utilisation -- the metric most surveys forget:
$ sudo iw dev wlan0 survey dump | awk '
> /frequency/{f=$2}
> /channel active time/{a=$4}
> /channel busy time/{b=$4; if(a>0) printf "%s MHz: %.0f%% busy\n", f, b/a*100}'
# 5180 MHz: 61% busy <- above the 40% target: congested
# 5200 MHz: 12% busy
# 5745 MHz: 3% busy <- move clients here
# Compute SNR at each survey point:
$ python3 -c "
sig, noise = -61, -95
print(f'SNR: {sig-noise} dB -> {\"PASS\" if sig-noise >= 25 else \"FAIL\"}')"
# SNR: 34 dB -> PASS
# Active survey: the measurement that reflects user experience:
$ iperf3 -c 192.168.1.1 -t 10 -R | tail -3
# [ 5] 0.00-10.00 sec 512 MBytes 429 Mbits/sec receiver
$ iw dev wlan0 station dump | grep -E 'tx retries|tx failed'
# tx retries: 1204
# tx failed: 12
# Retries under 5% of packets is healthy; above 15% indicates interference.
# Count co-channel neighbours at each AP location:
$ sudo iw dev wlan0 scan | awk '/freq:/{f=$2} /signal:/{if($2>-82) c[f]++}
> END{for(k in c) print k" MHz:", c[k], "APs above -82 dBm"}' | sort -n
# 2437 MHz: 11 APs above -82 dBm <- severe co-channel contention
# 5180 MHz: 3 APs above -82 dBm
# 5745 MHz: 1 APs above -82 dBm
# Verify the channel plan has no adjacent-AP collisions:
$ sudo iw dev wlan0 scan | grep -E '^BSS|freq:|signal:' | paste - - - | \
grep -E 'signal: -[0-6]' | awk '{print $4}' | sort -n | uniq -c
# 2 5180 <- two STRONG APs on the same channel: replan one
# 1 5200
# 1 5745
# Set AP transmit power down for high-density design:
$ sudo iw dev wlan0 set txpower fixed 1100 # 11 dBm, in mBm
$ iw dev wlan0 info | grep txpower
# txpower 11.00 dBm
# Lower power = smaller cells = more reuse = more total capacity.
# Roaming validation: walk the site and log every transition:
$ sudo wpa_cli -i wlan0 -a /usr/local/bin/log-roam.sh &
$ journalctl -f | grep -E 'CTRL-EVENT-CONNECTED|roam'
# CTRL-EVENT-CONNECTED - Connection to 3c:37:86:1f:2a:9c completed
# CTRL-EVENT-CONNECTED - Connection to 3c:37:86:44:02:c1 completed
# Log timestamps to measure roam duration at each boundary.
# Record survey points with location tags for later analysis:
$ for point in reception office-n office-s cafeteria stairwell; do
> read -p "Move to $point, press enter..."
> echo "$point $(iw dev wlan0 link | awk '/signal/{print $2}') \
$(iw dev wlan0 survey dump | awk '/noise/{print $2; exit}')"
> done | tee survey-$(date +%F).txt
# reception -52 -95
# office-n -61 -94
# office-s -74 -93 <- below target, needs an AP
Key takeaways
- Models cannot predict real coverage — materials, furniture, and people dominate — so predictive, passive, and active surveys serve different stages.
- Standard targets: ≥ −67 dBm primary, ≥ −72 dBm secondary (a roaming candidate), ≥ 25 dB SNR, < 40% channel utilisation, 15–20% cell overlap.
- Modern design is capacity-driven, not coverage-driven: count devices per area, then deliberately reduce transmit power so cells stay small and channels can be reused.
- Co-channel APs share capacity rather than adding it — three APs on one channel deliver the total of one, so the channel plan matters more than the AP count.
- 2.4 GHz permits only a 3-channel reuse pattern, which is why it cannot be made dense; 5 and 6 GHz allow real planning.
- 5 GHz cells are ~35% smaller than 2.4 GHz at the same power, so a 2.4-only survey overestimates 5 GHz coverage.
- Survey the space as it will be used: an empty warehouse, an unstocked shelf, and a room without people all measure a building that will not exist in production.
🧪 Practice
- Compute the cell radius for a 20 dBm AP with a 3 dBi antenna reaching −67 dBm at 5 GHz with a path-loss exponent of 3.5.
- A lecture hall holds 300 devices needing 1.5 Mb/s each at 30% concurrency. Compute the APs required and explain why you would lower their power.
- Explain why three APs on channel 36 in one room deliver no more capacity than one.
- Interview-style: A survey of a new warehouse passes every target, but the network fails once operations begin. (Hint: ask what was in the building on survey day and what is in it now — then recall what absorbs 2.4 GHz particularly well.)
<a id="103-cellular-networks"></a>
10.3 Cellular Networks
Cellular networks solve the same physics as Wi-Fi with licensed spectrum, centralised control, and nationwide mobility: this subchapter covers their structure, evolution, and core components.
Cellular Architecture and Handoff
Theory
Cellular networks answer a question Wi-Fi never seriously attempted: how do you cover an entire country, serve millions of simultaneous users, and keep a call alive while someone drives at 120 km/h?
The founding insight, from Bell Labs in the 1940s, is frequency reuse. A single high-power transmitter covering a whole city can support only as many calls as its spectrum allows. Instead, divide the area into small cells, each with a low-power base station, and reuse the same frequencies in cells far enough apart that they do not interfere. Capacity then scales with the number of cells rather than with the amount of spectrum.
This is the same principle as reducing Wi-Fi AP power for high density, applied systematically and nationally.
Cells are conventionally drawn as hexagons — not because coverage is hexagonal (it is roughly circular and irregular) but because hexagons tile a plane with no gaps or overlaps, making reuse patterns easy to reason about. The reuse factor N is the number of cells in a repeating cluster; typical values are 3, 4, 7, or 12. A larger N means greater separation between co-channel cells, so less interference but fewer channels per cell.
The co-channel reuse ratio captures the trade-off:
Q = D/R = √(3N)
where D is the distance between co-channel cells and R is the cell radius. Larger Q means better signal-to-interference ratio and lower capacity.
Cells are sized to demand, not to geography:
| Cell type | Radius | Use |
|---|---|---|
| Macrocell | 1–30 km | Rural coverage, highways |
| Microcell | 200 m – 2 km | Urban streets |
| Picocell | 10–200 m | Malls, offices, stations |
| Femtocell | 10–50 m | Homes, small businesses |
Sectorisation further multiplies capacity: a tower with three 120-degree directional antennas creates three cells from one site, tripling capacity and reducing interference, since each antenna hears only a third of the surroundings.
Handoff (handover) is what distinguishes cellular from Wi-Fi most sharply. Recall from 10.2 that Wi-Fi roaming is a client decision — the AP cannot move a client. In cellular, the network decides. The device reports measurements of neighbouring cells, and the network commands the handover. This centralised control is why a call survives a motorway and a Wi-Fi session often does not survive a corridor.
Two types matter:
- Hard handover ("break before make"): release the old cell, then connect to the new one. Used by GSM and LTE. Simple, but a brief interruption exists.
- Soft handover ("make before break"): connect to the new cell while still connected to the old, then release. Used by CDMA/3G, where the device can combine signals from multiple towers — macro diversity. It is more robust but consumes resources in several cells at once.
LTE and 5G use hard handover with heavy optimisation, achieving interruptions around 30–50 ms, short enough for voice.
Examples
Frequency reuse: capacity from geometry, not spectrum
N = 7 reuse pattern (7 channel groups, repeating)
(2) (3)
(7) (1) (4)
(6) (5)
(2) (3)
(7) (1) (4) <- cell 1 reappears here
(6) (5)
D = distance between two cell-1s
R = cell radius
Q = D/R = sqrt(3N) = sqrt(21) = 4.58
Larger N -> co-channel cells further apart -> less interference
-> but FEWER channels per cell -> less capacity per cell
Total system capacity = (channels per cell) x (number of cells)
-> shrink the cells, and capacity grows without any new spectrum.
SECTORISATION: three cells from one tower
omnidirectional 3-sector
___ \ | /
/ \ \ | /
| o | one cell \|/ three 120-degree cells
\___/ o from ONE site
/|\
1 cell per site / | \ 3x capacity
hears interference from all each antenna hears only 1/3
directions of the surrounding interference
HANDOFF: who decides?
WI-FI (10.2) CELLULAR
the CLIENT decides the NETWORK decides
AP cannot move a client network COMMANDS the handover
-> sticky clients -> device reports measurements,
-> vendor-dependent behaviour network chooses and instructs
-> 50-600 ms, often disruptive -> 30-50 ms, planned
This single difference is why a call survives a motorway
and a video call often does not survive walking down a corridor.
HARD vs SOFT HANDOVER
HARD (GSM, LTE, 5G) SOFT (CDMA / 3G)
"break before make" "make before break"
[cell A]---X [cell A]---\
\ >--- device
\--- [cell B] [cell B]---/
brief gap (~30-50 ms) connected to BOTH, signals COMBINED
simple, efficient macro diversity: more robust
but occupies resources in both cells
import math
# --- Frequency reuse arithmetic ---
def reuse_ratio(n):
"""Q = D/R = sqrt(3N): how far apart co-channel cells sit."""
return math.sqrt(3 * n)
def sir_db(n, path_loss_exponent=4, interferers=6):
"""Signal-to-interference ratio from the reuse geometry alone."""
q = reuse_ratio(n)
return 10 * math.log10(q ** path_loss_exponent / interferers)
print(f"{'N':>4}{'Q = D/R':>10}{'SIR':>10}{'channels/cell (of 350)':>26}")
for n in (1, 3, 4, 7, 12, 19):
print(f"{n:>4}{reuse_ratio(n):>10.2f}{sir_db(n):>8.1f} dB{350//n:>22}")
# N Q = D/R SIR channels/cell (of 350)
# 1 1.73 -2.5 dB 350
# 3 3.00 11.3 dB 116
# 4 3.46 13.8 dB 87
# 7 4.58 18.7 dB 50
# 12 6.00 23.3 dB 29
# 19 7.55 27.3 dB 18
# The trade-off is explicit: N=7 gives 18.7 dB SIR (workable) with 50
# channels per cell. N=1 maximises channels and makes the system unusable.
# --- Capacity from cell splitting: the central insight ---
def system_capacity(area_km2, cell_radius_km, channels_per_cell, sectors=1):
cell_area = 2.6 * cell_radius_km ** 2 # hexagon area = 2.598 R^2
cells = area_km2 / cell_area
return cells * channels_per_cell * sectors, cells
print(f"\ncovering 100 km2 with N=7 reuse (50 channels/cell):")
print(f"{'cell radius':>13}{'cells':>9}{'omni capacity':>16}{'3-sector':>12}")
for r in (10, 5, 2, 1, 0.5):
cap1, cells = system_capacity(100, r, 50, 1)
cap3, _ = system_capacity(100, r, 50, 3)
print(f"{r:>11} km{cells:>9.0f}{cap1:>14,.0f} ch{cap3:>10,.0f} ch")
# cell radius cells omni capacity 3-sector
# 10 km 0 19 ch 58 ch
# 5 km 2 77 ch 231 ch
# 2 km 10 481 ch 1,442 ch
# 1 km 38 1,923 ch 5,769 ch
# 0.5 km 154 7,692 ch 23,077 ch
# SAME spectrum throughout. Capacity grew 400x purely by shrinking cells.
# This is why dense urban areas have a base station every few hundred metres.
# --- Cell types and their roles ---
CELLS = [("macrocell", 1, 30, "rural, highways, wide coverage"),
("microcell", 0.2, 2, "urban streets, capacity infill"),
("picocell", 0.01, 0.2, "malls, offices, stations"),
("femtocell", 0.01, 0.05, "homes, small business")]
print(f"\n{'type':<12}{'radius':>16}{'cells per 100 km2':>20} use")
for name, rmin, rmax, use in CELLS:
_, cells = system_capacity(100, rmax, 50)
print(f"{name:<12}{f'{rmin}-{rmax} km':>16}{cells:>20,.0f} {use}")
# type radius cells per 100 km2 use
# macrocell 1-30 km 0 rural, highways, wide coverage
# microcell 0.2-2 km 10 urban streets, capacity infill
# picocell 0.01-0.2 km 961 malls, offices, stations
# femtocell 0.01-0.05 km 15,385 homes, small business
# --- Handover decision logic ---
def handover_decision(serving_dbm, neighbour_dbm, hysteresis_db=3,
time_to_trigger_ms=320, elapsed_ms=0):
"""3GPP event A3: neighbour becomes better than serving by a margin."""
if neighbour_dbm <= serving_dbm + hysteresis_db:
return "no trigger", 0
if elapsed_ms < time_to_trigger_ms:
return f"condition met, waiting ({elapsed_ms}/{time_to_trigger_ms} ms)", elapsed_ms
return "HANDOVER COMMANDED by the network", elapsed_ms
print(f"\ndriving between cells (measurements every 80 ms):")
print(f"{'t':>7}{'serving':>10}{'neighbour':>11} decision")
elapsed = 0
track = [(-72, -88), (-78, -82), (-84, -76), (-86, -72), (-89, -68), (-92, -64)]
for i, (serv, neigh) in enumerate(track):
t = i * 80
if neigh > serv + 3:
elapsed += 80
else:
elapsed = 0
decision, _ = handover_decision(serv, neigh, elapsed_ms=elapsed)
print(f"{t:>5}ms{serv:>9}dB{neigh:>10}dB {decision}")
# t serving neighbour decision
# 0ms -72dB -88dB no trigger
# 80ms -78dB -82dB no trigger
# 160ms -84dB -76dB condition met, waiting (80/320 ms)
# 240ms -86dB -72dB condition met, waiting (160/320 ms)
# 320ms -89dB -68dB condition met, waiting (240/320 ms)
# 400ms -92dB -64dB condition met, waiting (320/320 ms)
# Time-to-trigger prevents PING-PONG from a momentary fade -- the same
# role hysteresis plays in Wi-Fi roaming, but enforced by the NETWORK.
# --- Hard vs soft handover ---
def handover_cost(kind, cell_resources=1):
if kind == "hard":
return {"interruption_ms": 35, "resources_used": cell_resources,
"robustness": "brief gap at the boundary"}
return {"interruption_ms": 0, "resources_used": cell_resources * 2,
"robustness": "macro diversity: signals combined from both cells"}
print()
for kind in ("hard", "soft"):
c = handover_cost(kind)
print(f" {kind:<6} interruption {c['interruption_ms']:>3} ms, "
f"resources {c['resources_used']}x, {c['robustness']}")
# hard interruption 35 ms, resources 1x, brief gap at the boundary
# soft interruption 0 ms, resources 2x, macro diversity: signals combined...
# LTE/5G chose HARD handover with heavy optimisation: 35 ms is short
# enough for voice, and halving resource use matters at national scale.
# --- Why handover speed matters at vehicle speeds ---
def cell_crossing_time(cell_radius_m, speed_kmh):
speed_ms = speed_kmh / 3.6
return 2 * cell_radius_m / speed_ms
print(f"\n{'speed':>10}{'macrocell 5km':>16}{'microcell 500m':>17}{'picocell 50m':>15}")
for kmh in (5, 50, 120, 300):
row = "".join(f"{cell_crossing_time(r, kmh):>14.0f} s"
for r in (5000, 500, 50))
print(f"{kmh:>7} kmh{row}")
# speed macrocell 5km microcell 500m picocell 50m
# 5 kmh 7200 s 720 s 72 s
# 50 kmh 720 s 72 s 7 s
# 120 kmh 300 s 30 s 3 s
# 300 kmh 120 s 12 s 1 s
# A high-speed train through picocells would hand over every SECOND.
# This is why high-speed rail uses large cells and dedicated corridors.
# Which cell is this device attached to, and how strong is it?
$ mmcli -m 0 | grep -A6 'Status'
# Status | state: connected
# | access tech: lte
# | signal quality: 68% (recent)
$ mmcli -m 0 --signal-get
# LTE | rssi: -61.00 dBm
# | rsrp: -89.00 dBm <- reference signal received power
# | snr: 14.20 dB
# | rsrq: -10.00 dB <- reference signal received quality
# RSRP is the cellular equivalent of Wi-Fi's signal: -80 good, -100 poor.
# Cell identity and tracking area:
$ mmcli -m 0 --location-get
# 3GPP | operator code: 23415
# | location area code: 0A1B
# | cell id: 0012A5F3
# | tracking area code: 1234
# The cell ID changes on every handover; the tracking area changes rarely.
# Watch handovers occur while moving:
$ while true; do
> mmcli -m 0 --location-get 2>/dev/null | awk -F: '/cell id/{print strftime("%T"), $2}'
> sleep 2
> done
# 09:14:22 0012A5F3
# 09:14:34 0012A5F3
# 09:14:46 0012A604 <- handover: new cell ID
# 09:15:02 0012A611 <- another
# Neighbouring cells the device is measuring:
$ mmcli -m 0 --command='AT+QENG="neighbourcell"'
# +QENG: "neighbourcell intra","LTE",1850,142,-92,-12,-64,0,...
# +QENG: "neighbourcell inter","LTE",3050,411,-88,-9,-61,0,...
# ^^^ RSRP of the neighbour
# The device reports these; the NETWORK decides whether to hand over.
# Access technology in use (tells you the generation):
$ mmcli -m 0 | grep 'access tech'
# | access tech: lte, 5gnr
# Both = 5G NSA (non-standalone): LTE anchor plus 5G carrier.
# Signal quality thresholds worth knowing:
$ python3 -c "
for rsrp in (-75, -85, -95, -105, -115):
q = ('excellent' if rsrp > -80 else 'good' if rsrp > -90 else
'fair' if rsrp > -100 else 'poor' if rsrp > -110 else 'no service')
print(f'RSRP {rsrp:>5} dBm -> {q}')"
# RSRP -75 dBm -> excellent
# RSRP -85 dBm -> good
# RSRP -95 dBm -> fair
# RSRP -105 dBm -> poor
# RSRP -115 dBm -> no service
# Band and bandwidth in use -- the capacity available to this cell:
$ mmcli -m 0 --command='AT+QCAINFO'
# +QCAINFO: "PCC",1850,100,"LTE BAND 3",1,142,-92,-12,-64,18
# ^^^ 100 = 20 MHz bandwidth
# +QCAINFO: "SCC",3050,75,"LTE BAND 7",1,411,...
# Carrier aggregation: two carriers bonded, like Wi-Fi channel bonding.
# Estimate cell density from a drive test log:
$ awk '{print $2}' celllog.txt | sort -u | wc -l
# 47
$ python3 -c "print(f'47 cells over a 12 km route -> ~{12000/47:.0f} m between cells')"
# 47 cells over a 12 km route -> ~255 m between cells
# ~255 m spacing indicates dense urban microcells.
Key takeaways
- Frequency reuse is the founding idea: small cells reusing the same spectrum far enough apart, so capacity scales with cell count rather than with spectrum.
- Hexagons are a modelling convenience, not a coverage shape — they tile without gaps, making reuse patterns tractable.
- The reuse factor N trades interference against capacity:
Q = √(3N), with larger N giving better SIR but fewer channels per cell. - Cell splitting multiplies capacity without new spectrum — shrinking a 10 km cell to 0.5 km increased capacity ~400× in the example.
- Sectorisation (typically three 120° antennas) triples capacity per site and reduces interference.
- Handover is network-controlled in cellular, unlike Wi-Fi's client-controlled roaming — which is why calls survive motorways and Wi-Fi sessions often fail in corridors.
- Hard handover ("break before make", ~30–50 ms) is used by GSM/LTE/5G; soft handover ("make before break") gives macro diversity but consumes resources in multiple cells.
🧪 Practice
- Compute Q and the approximate SIR for reuse factors N = 4 and N = 12, and state which suits a dense urban area.
- Explain how cell splitting increases capacity without acquiring additional spectrum.
- Contrast Wi-Fi roaming with cellular handover in terms of who decides and what the consequence is.
- Interview-style: Why can a voice call survive a car journey at 120 km/h while a Wi-Fi video call often drops walking between rooms? (Hint: compare who initiates the transition in each system, and what information that party has about the alternatives.)
Evolution from 2G to 5G
Theory
Each cellular generation solved the dominant problem of its era, and the sequence reads as a steady conversion of a telephone network into an IP network.
1G (1980s) was analogue voice — FDMA, no encryption, easily cloned. Its value here is as a baseline.
2G (GSM) digitised voice, which brought encryption, better spectral efficiency, and — almost as an afterthought — SMS. It used TDMA: each user gets a time slot on a shared frequency. Data arrived as GPRS (up to 114 kb/s) and EDGE (384 kb/s), both packet-switched overlays on a circuit-switched network. That hybrid nature is the defining awkwardness of 2G data.
3G (UMTS/CDMA2000) was designed for data from the start. CDMA replaced TDMA: all users transmit simultaneously on the same frequency, separated by orthogonal codes. HSPA+ eventually reached ~42 Mb/s. Voice remained circuit-switched, so the network still maintained two parallel domains.
4G (LTE) made the decisive architectural break: everything is IP, including voice. The circuit-switched domain disappeared, and voice became an application (VoLTE) over the packet network. The radio moved to OFDMA, the same family of techniques as Wi-Fi, with MIMO and carrier aggregation. The flat, all-IP architecture removed several node types and cut latency substantially.
5G (NR) is best understood as three different services sharing one framework, rather than as "faster 4G":
- eMBB (enhanced Mobile Broadband) — multi-gigabit throughput; the consumer-facing headline.
- URLLC (Ultra-Reliable Low-Latency Communication) — 1 ms air latency at 99.999% reliability, for industrial control, vehicles, and remote surgery.
- mMTC (massive Machine-Type Communication) — up to a million devices per square kilometre for sensors.
These have contradictory requirements, which is precisely why network slicing (a later topic) exists.
5G also introduced a spectrum split that explains most consumer confusion about it:
| Range | Frequencies | Speed | Coverage |
|---|---|---|---|
| Low-band | < 1 GHz | ~100 Mb/s | Tens of km |
| Mid-band | 1–6 GHz | ~1 Gb/s | A few km |
| mmWave | 24–40 GHz | ~10 Gb/s | 100–300 m, blocked by walls and hands |
The multi-gigabit demonstrations use mmWave, which barely penetrates a window; the 5G most people actually experience is low or mid-band. Mid-band is where 5G's real value lies.
One further distinction matters operationally: NSA (Non-Standalone) 5G uses a 5G radio anchored to an existing LTE core, which is how most networks launched. SA (Standalone) 5G uses a native 5G core and is required for slicing, URLLC, and the low latencies 5G promises. A phone showing "5G" on an NSA network gets the radio improvements but none of the architectural ones.
Examples
The generations, and what each one actually changed
1G analogue voice FDMA no encryption, clonable
2G DIGITAL voice TDMA encryption, SMS, GPRS/EDGE data
3G data-capable CDMA HSPA+ ~42 Mb/s, voice still circuit
4G ALL-IP OFDMA voice becomes an app (VoLTE)
5G three services OFDMA+ eMBB / URLLC / mMTC, slicing
The through-line: a telephone network gradually became an IP network,
and in 4G the telephone part finally disappeared entirely.
MULTIPLE ACCESS: how users are separated
FDMA (1G) TDMA (2G) CDMA (3G) OFDMA (4G/5G)
freq freq freq freq
^ [A] ^ [A|B|C|A|B|C] ^ [ABC mixed] ^ [A][B][C]
| [B] | | all at once | [B][C][A]
| [C] | | separated by | [C][A][B]
+------> time +------> time +--> by CODE +-----> time
each user gets each user gets all users share users get
a FREQUENCY a TIME SLOT everything, time-frequency
separated by BLOCKS
orthogonal codes
5G's THREE SERVICES have contradictory needs
throughput latency density reliability
eMBB 10 Gb/s ~10 ms normal normal
URLLC low 1 ms normal 99.999%
mMTC tiny tolerant 1M/km2 normal
One radio cannot optimise for all three simultaneously.
-> hence NETWORK SLICING: virtual networks with different parameters.
THE 5G SPECTRUM CONFUSION
low-band <1 GHz ~100 Mb/s tens of km "5G" on your phone, often
barely faster than LTE
mid-band 1-6 GHz ~1 Gb/s a few km THE USEFUL ONE
mmWave 24-40 GHz ~10 Gb/s 100-300 m the demo videos;
blocked by walls, leaves,
rain, and your own hand
Marketing shows mmWave. Most users get low or mid-band.
import math
# --- Generation comparison ---
GENERATIONS = [
# name, year, access, peak_dl_mbps, latency_ms, voice, spectral_eff
("1G (AMPS)", 1983, "FDMA", 0.0024, 500, "analogue circuit", 0.06),
("2G (GSM)", 1991, "TDMA", 0.014, 300, "digital circuit", 0.17),
("2.5G (GPRS)", 2000, "TDMA", 0.114, 700, "circuit", 0.20),
("3G (UMTS)", 2001, "CDMA", 2.0, 200, "circuit", 0.51),
("3.5G (HSPA+)",2008, "CDMA", 42.0, 100, "circuit", 1.50),
("4G (LTE)", 2009, "OFDMA", 300.0, 50, "VoLTE (IP)", 3.00),
("4G (LTE-A)", 2013, "OFDMA", 1000.0, 30, "VoLTE (IP)", 6.00),
("5G (NR)", 2019, "OFDMA", 10000.0, 1, "VoNR (IP)", 30.00),
]
print(f"{'generation':<15}{'year':>6}{'access':>8}{'peak DL':>12}{'latency':>10}"
f"{'spectral eff':>14}")
for name, yr, acc, dl, lat, voice, se in GENERATIONS:
dl_s = f"{dl*1000:.1f} kb" if dl < 1 else f"{dl:,.0f} Mb"
print(f"{name:<15}{yr:>6}{acc:>8}{dl_s:>12}{lat:>8} ms{se:>12.2f} b/Hz")
# generation year access peak DL latency spectral eff
# 1G (AMPS) 1983 FDMA 2.4 kb 500 ms 0.06 b/Hz
# 2G (GSM) 1991 TDMA 14.0 kb 300 ms 0.17 b/Hz
# 2.5G (GPRS) 2000 TDMA 114.0 kb 700 ms 0.20 b/Hz
# 3G (UMTS) 2001 CDMA 2 Mb 200 ms 0.51 b/Hz
# 3.5G (HSPA+) 2008 CDMA 42 Mb 100 ms 1.50 b/Hz
# 4G (LTE) 2009 OFDMA 300 Mb 50 ms 3.00 b/Hz
# 4G (LTE-A) 2013 OFDMA 1,000 Mb 30 ms 6.00 b/Hz
# 5G (NR) 2019 OFDMA 10,000 Mb 1 ms 30.00 b/Hz
# Spectral efficiency rose 500x from 1G to 5G -- that is the real story,
# not peak rate, which also benefited from far more spectrum.
# How much of the gain is spectrum vs efficiency?
def decompose_gain(gen_a, gen_b, bw_a, bw_b):
se_gain = gen_b[6] / gen_a[6]
bw_gain = bw_b / bw_a
return se_gain, bw_gain, se_gain * bw_gain
se, bw, total = decompose_gain(GENERATIONS[1], GENERATIONS[7], 0.2, 100)
print(f"\n2G -> 5G peak-rate gain decomposed:")
print(f" spectral efficiency : {se:>8.0f}x")
print(f" bandwidth : {bw:>8.0f}x")
print(f" combined : {total:>8,.0f}x")
# 2G -> 5G peak-rate gain decomposed:
# spectral efficiency : 176x
# bandwidth : 500x
# combined : 88,235x
# MORE SPECTRUM did more of the work than cleverer radios did.
# --- 5G spectrum bands: the marketing vs reality gap ---
def coverage_area_km2(radius_km):
return math.pi * radius_km ** 2
BANDS_5G = [("low-band (700 MHz)", 0.7, 100, 10.0),
("mid-band (3.5 GHz)", 3.5, 1000, 1.5),
("mmWave (28 GHz)", 28.0, 10000, 0.2)]
print(f"\n{'band':<22}{'peak':>10}{'radius':>9}{'area':>12}{'sites/100km2':>15}")
for name, ghz, mbps, radius in BANDS_5G:
area = coverage_area_km2(radius)
sites = math.ceil(100 / area)
print(f"{name:<22}{mbps:>7,} Mb{radius:>8} km{area:>10.2f} km2{sites:>14,}")
# band peak radius area sites/100km2
# low-band (700 MHz) 100 Mb 10 km 314.16 km2 1
# mid-band (3.5 GHz) 1,000 Mb 1.5 km 7.07 km2 15
# mmWave (28 GHz) 10,000 Mb 0.2 km 0.13 km2 796
# mmWave needs ~800 sites where low-band needs 1. That economics is why
# mmWave exists only in stadiums and a few city blocks.
# What actually blocks mmWave:
BLOCKERS = {"a brick wall": 40, "a window (low-e glass)": 25, "foliage": 15,
"a human body": 20, "heavy rain (per km)": 10, "your hand": 15}
print(f"\nmmWave attenuation (link budget ~30 dB of margin):")
for what, db in BLOCKERS.items():
verdict = "LINK LOST" if db >= 25 else "severe" if db >= 15 else "noticeable"
print(f" {what:<26}{db:>4} dB {verdict}")
# a brick wall 40 dB LINK LOST
# a window (low-e glass) 25 dB LINK LOST
# foliage 15 dB severe
# a human body 20 dB severe
# heavy rain (per km) 10 dB noticeable
# your hand 15 dB severe
# Holding the phone differently can drop a mmWave link. This is not a
# defect; it is physics at 28 GHz.
# --- NSA vs SA: what a "5G" indicator actually means ---
def five_g_capabilities(mode):
return {
"NSA (non-standalone)": {
"5G radio": True, "5G core": False, "network slicing": False,
"URLLC (1 ms)": False, "VoNR": False,
"note": "LTE anchor required; most launch networks",
},
"SA (standalone)": {
"5G radio": True, "5G core": True, "network slicing": True,
"URLLC (1 ms)": True, "VoNR": True,
"note": "native 5G core; required for 5G's architectural promises",
},
}[mode]
print()
for mode in ("NSA (non-standalone)", "SA (standalone)"):
caps = five_g_capabilities(mode)
note = caps.pop("note")
print(f"{mode}")
for k, v in caps.items():
print(f" {k:<20}{'yes' if v else 'NO'}")
print(f" -> {note}\n")
# A phone showing "5G" on an NSA network gets faster radio and nothing else.
# Slicing, URLLC, and 1 ms latency all require SA.
# --- Latency: where 5G's 1 ms claim applies ---
def end_to_end_latency_ms(air_ms, ran_ms, core_ms, internet_ms):
return air_ms + ran_ms + core_ms + internet_ms
print(f"{'scenario':<34}{'air':>6}{'RAN':>6}{'core':>6}{'net':>7}{'total':>8}")
SCENARIOS = [("4G to a distant server", 10, 5, 10, 40),
("5G to a distant server", 1, 2, 5, 40),
("5G to an edge node (MEC)", 1, 2, 1, 1),
("5G URLLC, on-premises core", 1, 1, 0, 0)]
for name, a, r, c, n in SCENARIOS:
print(f"{name:<34}{a:>5}{r:>6}{c:>6}{n:>7}{end_to_end_latency_ms(a,r,c,n):>7}ms")
# scenario air RAN core net total
# 4G to a distant server 10 5 10 40 65ms
# 5G to a distant server 1 2 5 40 48ms
# 5G to an edge node (MEC) 1 2 1 1 5ms
# 5G URLLC, on-premises core 1 1 0 0 2ms
# The "1 ms" figure is AIR latency only. End to end, the internet path
# dominates -- which is exactly why edge computing accompanies 5G.
# Which generation and band is this device using?
$ mmcli -m 0 | grep -E 'access tech|signal quality'
# | access tech: lte, 5gnr
# | signal quality: 71% (recent)
# "lte, 5gnr" together = NSA 5G: an LTE anchor plus a 5G carrier.
# Distinguish NSA from SA:
$ mmcli -m 0 --command='AT+QNWPREFCFG="nr5g_disable_mode"'
$ mmcli -m 0 --command='AT+QENG="servingcell"'
# +QENG: "servingcell","NOCONN","NR5G-SA","TDD",234,15,...
# ^^^^^^^ SA = standalone, native 5G core
# +QENG: "servingcell","NOCONN","LTE","FDD",234,15,... ,"NR5G-NSA",...
# ^^^ LTE anchor with an NSA 5G carrier
# Which band, and therefore which experience to expect:
$ mmcli -m 0 --command='AT+QNWINFO'
# +QNWINFO: "NR5G-SA","23415","NR5G BAND 78",636666
# ^^^^^^^^^^^^^ n78 = 3.5 GHz mid-band
# n78 (3.5 GHz) = mid-band, the useful one.
# n71 (600 MHz) = low-band, wide coverage, modest speed.
# n257/n258/n260 = mmWave, extreme speed, tiny range.
# Measure the latency that actually matters to applications:
$ ping -c 20 -q 8.8.8.8 | tail -2
# 20 packets transmitted, 20 received, 0% packet loss
# rtt min/avg/max/mdev = 28.1/34.7/58.2/6.4 ms
# 34 ms on "5G" -- because the internet path dominates, not the air interface.
$ ping -c 20 -q <operator-edge-node> | tail -1
# rtt min/avg/max/mdev = 6.2/8.1/12.4/1.4 ms
# The 5G latency promise requires the server to be at the EDGE.
# Carrier aggregation in use (the cellular equivalent of channel bonding):
$ mmcli -m 0 --command='AT+QCAINFO'
# +QCAINFO: "PCC",504990,12,"NR5G BAND 78",1,...
# +QCAINFO: "SCC",1850,100,"LTE BAND 3",1,...
# +QCAINFO: "SCC",3050,75,"LTE BAND 7",1,...
# One 5G carrier plus two LTE carriers bonded together.
# Compare throughput across generations on the same device:
$ for tech in lte 5gnr; do
> mmcli -m 0 --set-allowed-modes=$tech >/dev/null 2>&1
> sleep 10
> echo -n "$tech: "; curl -s -o /dev/null -w '%{speed_download}\n' \
> https://speed.example.com/100MB.bin | awk '{printf "%.0f Mb/s\n", $1*8/1e6}'
> done
# lte: 118 Mb/s
# 5gnr: 412 Mb/s
# VoLTE / VoNR: voice as an IP application, not a circuit:
$ mmcli -m 0 | grep -i 'ims\|voice'
# Bearer | ims: registered
# Voice now rides the packet core. In 2G/3G it had a separate domain.
# Check the operator's core deployment via the registered PLMN and features:
$ mmcli -m 0 --3gpp-scan 2>/dev/null | head -4
# operator code: 23415 name: Vodafone tech: 5gnr availability: current
Key takeaways
- The generations trace a telephone network becoming an IP network, completed in 4G when the circuit-switched domain disappeared and voice became VoLTE.
- Multiple access evolved FDMA → TDMA → CDMA → OFDMA, each separating users along a different axis: frequency, time, code, then time-frequency blocks.
- 2G digitised voice and added SMS and packet data overlays; 3G was data-first but kept circuit-switched voice; 4G went all-IP with a flat architecture.
- 5G is three services in one framework — eMBB, URLLC, mMTC — with contradictory requirements, which is why network slicing exists.
- The 5G band split explains consumer confusion: mmWave delivers the demo speeds but is blocked by walls, foliage, and a hand; mid-band is where the real value is.
- NSA 5G gives radio improvements only; SA 5G with a native core is required for slicing, URLLC, and low latency.
- The "1 ms" figure is air latency; end-to-end latency is dominated by the path to the server, which is why edge computing accompanies 5G.
🧪 Practice
- State which generation first made voice an IP application and what disappeared from the architecture as a result.
- Explain why mmWave 5G needs roughly 800 sites per 100 km² where low-band needs one, and what that implies for deployment.
- A phone shows "5G" but latency to a distant server is 45 ms. Explain why, and what would have to change.
- Interview-style: A customer on an NSA 5G network asks why they cannot get the network slicing their vendor promised. (Hint: ask which core network their traffic actually traverses, and what "non-standalone" is standing on.)
LTE EPC and 5G Core Components
Theory
A cellular core network does several things a home router never has to: authenticate a SIM against a national subscriber database, track a device that may be anywhere in the country, enforce per-subscriber policy and billing, and preserve a device's IP address as it moves between cells hundreds of kilometres apart.
LTE's Evolved Packet Core (EPC) divides this among four main elements, and the division is worth learning because 5G rearranges rather than replaces it:
| Element | Role |
|---|---|
| MME (Mobility Management Entity) | Control plane: attach, authentication, mobility, paging, bearer setup |
| HSS (Home Subscriber Server) | Subscriber database: identities, keys, service profiles |
| S-GW (Serving Gateway) | User plane: anchors traffic during handover between base stations |
| P-GW (PDN Gateway) | User plane: allocates the IP address, enforces policy, connects to the internet |
| PCRF | Policy and charging rules |
The structural principle is control-plane / user-plane separation, which is the same idea encountered in 8.1's hybrid architectures and Chapter 13's SDN. The MME signals but never touches user traffic; the gateways forward traffic but make no decisions. This lets the two scale independently — a network may need far more forwarding capacity than signalling capacity, or the reverse.
Two concepts do the heavy lifting:
A bearer is a virtual connection between the device and the P-GW with defined QoS characteristics. The default bearer is established at attach and is best-effort; dedicated bearers carry specific traffic with guaranteed bit rate — a VoLTE call gets its own dedicated bearer, which is why voice quality holds up while data is congested. Each bearer has a QCI (QoS Class Identifier) specifying priority, delay budget, and loss tolerance.
Tunnelling with GTP is how mobility is hidden from the IP layer. User traffic is encapsulated between the base station and the gateways, so the device keeps one IP address allocated by the P-GW no matter which base station it is attached to. The tunnel endpoints change during handover; the IP address does not. This is the cellular answer to the mobility problem that Mobile IP addresses in 10.5, and it works better because the whole path is under one operator's control.
5G's Core (5GC) re-architects this as a Service-Based Architecture (SBA): network functions expose HTTP/2 REST APIs to one another rather than using purpose-built protocols over fixed interfaces. The mapping is roughly:
| LTE | 5G | Change |
|---|---|---|
| MME | AMF (Access and Mobility) + SMF (Session Management) | Split into two |
| S-GW + P-GW | UPF (User Plane Function) | Merged, and deployable at the edge |
| HSS | UDM + AUSF | Data and authentication separated |
| PCRF | PCF | Policy |
| — | NRF, NEF, NSSF | Service discovery, exposure, slice selection |
Three consequences matter. Splitting the MME into AMF and SMF lets mobility and session management scale separately. Merging the gateways into a UPF that can be placed anywhere — including at a cell site — is what makes edge computing and single-digit-millisecond latency achievable. And the service-based interfaces make the core a cloud-native software system rather than a set of specialised appliances, which is why 5G cores run on Kubernetes.
Examples
LTE EPC: control plane and user plane separated
+--------+
| HSS | subscriber database
+--------+
| (S6a: authentication vectors)
|
[UE] ---- radio ---- [eNodeB] ---- S1-MME ---- [MME] CONTROL PLANE
| | | (signalling only)
| | |
| +----- S1-U ------- [S-GW] --- [P-GW] --- internet
| | |
+------------- GTP tunnel (user traffic) ------+----------+
USER PLANE
(forwarding only)
The MME NEVER touches user traffic.
The gateways NEVER make policy decisions.
-> the two scale independently.
5G CORE: service-based, and the UPF moves to the edge
+-----------------------------------+
| service-based interfaces (HTTP/2)|
| NRF NEF NSSF PCF UDM AUSF |
+-----------------------------------+
| |
[AMF] [SMF] <- MME split in two
| |
[UE] --- radio --- [gNodeB] --- [UPF] --- data network
^
| the UPF can sit AT THE CELL SITE,
| which is what makes 1-5 ms possible
LTE: MME -> 5G: AMF (mobility) + SMF (sessions)
LTE: S-GW+P-GW -> 5G: UPF (merged, distributable)
LTE: HSS -> 5G: UDM (data) + AUSF (authentication)
BEARERS: why your voice call survives a congested cell
+---------------------- device ----------------------+
| |
| DEFAULT bearer (QCI 9, best effort) |
| -> web, email, app traffic: competes for capacity|
| |
| DEDICATED bearer (QCI 1, guaranteed bit rate) |
| -> the VoLTE call: RESERVED capacity, 100 ms |
| delay budget, 1e-2 loss tolerance |
+----------------------------------------------------+
Congestion degrades the default bearer and leaves the dedicated one
intact. This is why calls stay clear while browsing crawls.
GTP TUNNELLING: how the IP address survives mobility
device IP 10.4.2.9 (allocated by the P-GW, never changes)
|
| encapsulated in GTP between eNodeB and S-GW
v
[eNodeB A] ==GTP==> [S-GW] ==GTP==> [P-GW] -> internet (sees 10.4.2.9)
|
| handover: the TUNNEL endpoint changes
v
[eNodeB B] ==GTP==> [S-GW] ==GTP==> [P-GW] -> internet (STILL 10.4.2.9)
The IP layer never learns the device moved. TCP connections survive.
# --- EPC and 5GC element mapping ---
MAPPING = [
("MME", "AMF + SMF", "control", "mobility and sessions split apart"),
("S-GW", "UPF", "user", "merged with P-GW"),
("P-GW", "UPF", "user", "merged; now DEPLOYABLE AT THE EDGE"),
("HSS", "UDM + AUSF","control", "subscriber data vs authentication split"),
("PCRF", "PCF", "control", "policy, now service-based"),
("-", "NRF", "control", "NEW: service discovery between functions"),
("-", "NSSF", "control", "NEW: network slice selection"),
("-", "NEF", "control", "NEW: exposes capabilities to third parties"),
]
print(f"{'LTE (EPC)':<10}{'5G (5GC)':<14}{'plane':<10}{'what changed'}")
for lte, fiveg, plane, note in MAPPING:
print(f"{lte:<10}{fiveg:<14}{plane:<10}{note}")
# --- Control/user plane separation: independent scaling ---
def dimension_core(subscribers, signalling_per_sub_per_hour=20,
mbps_per_active_sub=2, active_fraction=0.15):
signalling_tps = subscribers * signalling_per_sub_per_hour / 3600
user_gbps = subscribers * active_fraction * mbps_per_active_sub / 1000
return signalling_tps, user_gbps
print(f"\n{'subscribers':>13}{'signalling (tps)':>19}{'user plane':>14}")
for subs in (100_000, 1_000_000, 10_000_000):
tps, gbps = dimension_core(subs)
print(f"{subs:>13,}{tps:>17,.0f}{gbps:>11,.0f} Gb/s")
# subscribers signalling (tps) user plane
# 100,000 556 30 Gb/s
# 1,000,000 5,556 300 Gb/s
# 10,000,000 55,556 3,000 Gb/s
# These scale at very different rates and have very different hardware
# needs -- which is exactly why the planes are separated.
# --- QCI: the QoS classes that make VoLTE work ---
QCI_TABLE = [
(1, "GBR", 2, 100, 1e-2, "conversational voice (VoLTE)"),
(2, "GBR", 4, 150, 1e-3, "conversational video"),
(3, "GBR", 3, 50, 1e-3, "real-time gaming"),
(4, "GBR", 5, 300, 1e-6, "non-conversational video"),
(5, "non-GBR", 1, 100, 1e-6, "IMS signalling"),
(7, "non-GBR", 7, 100, 1e-3, "voice/video streaming"),
(8, "non-GBR", 8, 300, 1e-6, "premium data"),
(9, "non-GBR", 9, 300, 1e-6, "default bearer: best-effort data"),
]
print(f"\n{'QCI':>4}{'type':>10}{'priority':>10}{'delay':>9}{'loss':>9} service")
for qci, typ, pri, delay, loss, svc in QCI_TABLE:
print(f"{qci:>4}{typ:>10}{pri:>10}{delay:>7}ms{loss:>9.0e} {svc}")
# QCI type priority delay loss service
# 1 GBR 2 100ms 1e-02 conversational voice (VoLTE)
# 2 GBR 4 150ms 1e-03 conversational video
# ...
# 9 non-GBR 9 300ms 1e-06 default bearer: best-effort data
# Note QCI 1 tolerates 1% LOSS but demands 100 ms delay -- exactly the
# trade-off from 8.1: voice prefers a late-free stream over a perfect one.
# What happens to each bearer under congestion:
def bearer_under_congestion(qci_type, cell_load):
if qci_type == "GBR":
return "guaranteed rate maintained (admission control refuses new calls)"
degraded = max(0.05, 1 - cell_load)
return f"degraded to {degraded:.0%} of nominal throughput"
print(f"\ncell at 95% load:")
print(f" QCI 1 (GBR) : {bearer_under_congestion('GBR', 0.95)}")
print(f" QCI 9 (non-GBR) : {bearer_under_congestion('non-GBR', 0.95)}")
# QCI 1 (GBR) : guaranteed rate maintained (admission control refuses new calls)
# QCI 9 (non-GBR) : degraded to 5% of nominal throughput
# Your call is clear; your browsing is unusable. By design.
# --- GTP tunnelling: how the IP address survives ---
class GTPTunnel:
"""User traffic is encapsulated; the endpoint changes, the IP does not."""
def __init__(self, ue_ip, pgw):
self.ue_ip = ue_ip # allocated by the P-GW, PERMANENT for the session
self.pgw = pgw
self.enodeb = None
self.teid = None # tunnel endpoint identifier
self.handovers = 0
def attach(self, enodeb, teid):
self.enodeb, self.teid = enodeb, teid
def handover(self, new_enodeb, new_teid):
old = self.enodeb
self.enodeb, self.teid = new_enodeb, new_teid
self.handovers += 1
return f"{old} -> {new_enodeb}: TEID {self.teid}, UE IP UNCHANGED"
t = GTPTunnel("10.4.2.9", "pgw-lon-1")
t.attach("enb-0451", 0x1A2B3C4D)
print(f"\nUE IP: {t.ue_ip} (allocated by {t.pgw})")
for enb, teid in (("enb-0452", 0x1A2B3C4E), ("enb-0518", 0x1A2B3C4F),
("enb-0603", 0x1A2B3C50)):
print(f" {t.handover(enb, teid)}")
print(f"after {t.handovers} handovers, UE IP is still {t.ue_ip}")
# UE IP: 10.4.2.9 (allocated by pgw-lon-1)
# enb-0451 -> enb-0452: TEID 439041102, UE IP UNCHANGED
# enb-0452 -> enb-0518: TEID 439041103, UE IP UNCHANGED
# enb-0518 -> enb-0603: TEID 439041104, UE IP UNCHANGED
# after 3 handovers, UE IP is still 10.4.2.9
# TCP connections survive every handover, because the IP layer never
# learns the device moved. Contrast this with 10.5's Mobile IP.
# --- UPF at the edge: where 5G's latency actually comes from ---
def latency_by_upf_placement(placement):
return {"central (national DC)": {"backhaul_ms": 12, "note": "one UPF per country"},
"regional": {"backhaul_ms": 4, "note": "a few dozen sites"},
"edge (cell site)": {"backhaul_ms": 0.2,"note": "thousands of sites"}}[placement]
print(f"\n{'UPF placement':<26}{'air':>6}{'backhaul':>11}{'total':>9} deployment")
for p in ("central (national DC)", "regional", "edge (cell site)"):
d = latency_by_upf_placement(p)
total = 1 + d["backhaul_ms"]
print(f"{p:<26}{1:>5}ms{d['backhaul_ms']:>9}ms{total:>7.1f}ms {d['note']}")
# UPF placement air backhaul total deployment
# central (national DC) 1ms 12ms 13.0ms one UPF per country
# regional 1ms 4ms 5.0ms a few dozen sites
# edge (cell site) 1ms 0.2ms 1.2ms thousands of sites
# 5G's URLLC latency target is unachievable with a central core, no matter
# how good the radio is. The UPF MUST move closer -- hence edge computing.
# --- Service-based architecture: functions calling each other over HTTP/2 ---
SBI_EXAMPLE = {
"AMF -> AUSF": "POST /nausf-auth/v1/ue-authentications",
"AMF -> UDM": "GET /nudm-sdm/v2/{supi}/am-data",
"SMF -> PCF": "POST /npcf-smpolicycontrol/v1/sm-policies",
"SMF -> UPF": "PFCP session establishment (not HTTP: user plane)",
"any -> NRF": "GET /nnrf-disc/v1/nf-instances?target-nf-type=UDM",
}
print()
for call, api in SBI_EXAMPLE.items():
print(f" {call:<14}{api}")
# AMF -> AUSF POST /nausf-auth/v1/ue-authentications
# AMF -> UDM GET /nudm-sdm/v2/{supi}/am-data
# ...
# any -> NRF GET /nnrf-disc/v1/nf-instances?target-nf-type=UDM
# The core is now a set of REST microservices. This is why 5G cores
# run on Kubernetes and why 9.3's API design principles apply directly.
# The bearers currently established for this device:
$ mmcli -m 0 --list-bearers
# /org/freedesktop/ModemManager1/Bearer/0 [default]
# /org/freedesktop/ModemManager1/Bearer/1 [ims]
# Two bearers: default (data) and IMS (voice signalling).
$ mmcli -b 0
# Status | connected: yes
# | interface: wwan0
# IPv4 | address: 10.4.2.9
# | gateway: 10.4.2.1
# | mtu: 1428
# ^^^^ reduced for GTP encapsulation overhead
# The 1428 MTU is a direct consequence of tunnelling: the GTP header
# eats into the 1500-byte path MTU.
# The APN determines which P-GW/UPF, and therefore which policy, applies:
$ mmcli -m 0 --list-bearers --output-keys | grep -i apn
# bearer.properties.apn: internet
# Operators run separate APNs for internet, IMS (voice), and M2M --
# each terminating on a different gateway with different rules.
# Watch the IP address survive handovers:
$ while true; do
> printf "%s cell=%s ip=%s\n" "$(date +%T)" \
> "$(mmcli -m 0 --location-get 2>/dev/null | awk -F: '/cell id/{print $2}')" \
> "$(ip -4 addr show wwan0 | awk '/inet /{print $2}')"
> sleep 5
> done
# 09:14:22 cell= 0012A5F3 ip=10.4.2.9/30
# 09:14:32 cell= 0012A604 ip=10.4.2.9/30 <- cell changed, IP did NOT
# 09:14:47 cell= 0012A611 ip=10.4.2.9/30
# This is GTP tunnelling doing its job.
# Confirm a TCP connection survives a handover:
$ ping -i 0.2 -c 300 8.8.8.8 | tail -3 &
# (walk or drive between cells during the test)
# 300 packets transmitted, 298 received, 0.7% packet loss
# rtt min/avg/max/mdev = 28.1/35.2/412.8/22.1 ms
# ^^^^^ the handover shows as a LATENCY SPIKE,
# not as a connection reset
# QoS class in use for a voice call (needs operator-level visibility):
$ mmcli -m 0 --command='AT+CGEQOS?'
# +CGEQOS: 1,9,,,, <- QCI 9: default bearer, best effort
# +CGEQOS: 2,5,,,, <- QCI 5: IMS signalling
# +CGEQOS: 3,1,128,128,, <- QCI 1: GBR 128 kb/s -- the actual voice bearer
# The third appears only during a call and disappears afterwards.
# Observe GTP on an operator's own network (lab/core-side only):
$ sudo tcpdump -i any -n 'udp port 2152' -c 3
# IP 10.100.1.5.2152 > 10.100.2.9.2152: UDP, length 1424
# ^^^^ GTP-U: the user-plane tunnel port
# Inside each of these is the subscriber's actual IP packet.
# 5G core functions as Kubernetes services (operator/lab environment):
$ kubectl get svc -n 5gc
# NAME TYPE CLUSTER-IP PORT(S)
# amf ClusterIP 10.96.12.4 38412/SCTP,8080/TCP
# smf ClusterIP 10.96.12.5 8805/UDP,8080/TCP
# upf NodePort 10.96.12.6 2152/UDP
# nrf ClusterIP 10.96.12.7 8080/TCP
# ^^^^ HTTP/2 service-based interfaces
$ kubectl exec -n 5gc deploy/amf -- curl -s http://nrf:8080/nnrf-disc/v1/nf-instances\?target-nf-type=UDM | jq -c '.nfInstances[0].nfType'
# "UDM"
# Service discovery over REST -- exactly the patterns from 9.3.
Key takeaways
- The EPC divides work into MME (control: mobility, authentication, paging), HSS (subscriber database), and S-GW/P-GW (user plane forwarding and IP allocation).
- Control-plane / user-plane separation lets signalling and forwarding scale independently — the same principle as SDN and as 8.1's hybrid architectures.
- Bearers carry QoS: the default bearer is best-effort, while dedicated GBR bearers (QCI 1 for VoLTE) keep voice clear while data is congested.
- GTP tunnelling preserves the device's IP address across handovers, so TCP connections survive — the handover appears as a latency spike, not a reset.
- 5G splits the MME into AMF + SMF, merges the gateways into the UPF, and splits the HSS into UDM + AUSF.
- The UPF is deployable at the edge, which is what makes single-digit-millisecond latency achievable — a central core cannot meet URLLC targets regardless of radio quality.
- The 5G core is service-based: network functions call each other over HTTP/2 REST APIs, making it a cloud-native software system rather than specialised appliances.
🧪 Practice
- Map each LTE element (MME, S-GW, P-GW, HSS) to its 5G counterpart and state what changed in each case.
- Explain why a VoLTE call stays clear while web browsing crawls on a congested cell, naming the mechanism.
- Describe how GTP tunnelling keeps a TCP connection alive across a handover, and what an application observes instead.
- Interview-style: An operator promises 5 ms latency but measures 40 ms end to end. Where would you look first? (Hint: the air interface is only one term — find out how far the user plane function is from the cell site.)
Radio Access Network Concepts
Theory
The RAN (Radio Access Network) is everything between the device and the core: the antennas, the radios, and the baseband processing that turns radio waves into IP packets. It is where most of an operator's capital is spent, and where the interesting architectural changes of the last decade have happened.
The traditional design put everything at the tower: antenna, radio, and baseband unit in a cabinet at the base. This is simple and has two problems — each site is an isolated island that cannot coordinate with its neighbours, and every site needs power, cooling, and maintenance visits.
C-RAN (Centralised RAN) splits the base station in two. The RRH/RU (Remote Radio Head/Unit) stays at the antenna, doing only radio-frequency work. The BBU (Baseband Unit) moves to a central location serving many sites, connected by high-capacity fronthaul fibre. The benefits are real: coordination between cells becomes possible (joint scheduling, interference cancellation), baseband capacity can be pooled across sites that peak at different times, and there is less equipment to maintain in the field.
The cost is fronthaul bandwidth, and it is severe. The traditional CPRI interface carries raw digitised radio samples, requiring roughly 10 Gb/s per sector per 20 MHz carrier — far more than the user data it represents. A three-sector site with carrier aggregation can need 100 Gb/s of fronthaul to deliver a few gigabits of user traffic.
Functional splits address this. Rather than cutting between radio and baseband, the processing chain can be divided at one of eight defined points, trading fronthaul bandwidth against how much coordination remains possible. The widely adopted Split 7.2x keeps some physical-layer processing at the radio unit, cutting fronthaul requirements by roughly an order of magnitude while preserving most coordination benefits.
O-RAN is the industry effort to make these interfaces open and multi-vendor. Historically a base station was a single vendor's monolith; O-RAN defines standard interfaces so an operator can buy radio units from one supplier and baseband from another. It also introduces the RIC (RAN Intelligent Controller) — near-real-time and non-real-time — which allows programmable, policy-driven optimisation of the RAN, bringing SDN-style separation to the radio network.
Three further concepts appear constantly in practice:
Carrier aggregation bonds multiple frequency carriers, exactly as Wi-Fi bonds channels. A device can use an 800 MHz carrier for coverage and a 3.5 GHz carrier for capacity simultaneously.
Coordinated Multi-Point (CoMP) lets neighbouring cells cooperate — transmitting jointly to a cell-edge device or coordinating their scheduling to avoid interfering. This directly attacks the worst-served users.
Massive MIMO at the base station uses 64 or more antenna elements, applying the beamforming principles from 10.1 at a scale that allows dozens of simultaneous spatially-separated beams.
Examples
Three RAN architectures
TRADITIONAL (D-RAN) C-RAN O-RAN
everything at the tower baseband centralised open, multi-vendor
[antenna] [antenna] [antenna]
| | |
[radio] [RU: radio only] [O-RU] vendor A
| | |
[baseband] fronthaul (fibre) open fronthaul
| | |
[backhaul] -> core [BBU pool] serving [O-DU] vendor B
many sites |
isolated site | [O-CU] vendor C
no coordination [backhaul] -> core |
power+cooling per site [RIC] policy control
pooling + coordination |
[backhaul] -> core
THE FRONTHAUL PROBLEM
CPRI carries RAW DIGITISED SAMPLES, not user data:
20 MHz carrier, 2 antennas, 15-bit samples, I and Q
= 30.72 Msps x 2 x 15 x 2 = ~2.5 Gb/s
...to deliver maybe 150 Mb/s of actual user traffic
3 sectors x 4 carriers = ~30 Gb/s of fronthaul
for ~1.8 Gb/s of user data <- a 16x overhead
This is why FUNCTIONAL SPLITS exist: move some processing back to
the radio unit so the fronthaul carries something closer to data.
FUNCTIONAL SPLITS: where to cut the processing chain
RF -- [ADC] -- [FFT] -- [demap] -- [decode] -- [MAC] -- [RLC] -- [PDCP]
| | | | | |
split 8 split 7.1 7.2x split 6 split 5 split 2
| | | | | |
fronthaul: 30 Gb/s 3 Gb/s 2 Gb/s 1.5 Gb/s ~user rate
coordination: BEST ---------------------------------> LEAST
Split 7.2x is the industry's compromise: ~10x less fronthaul than CPRI,
most coordination benefits retained.
import math
# --- Fronthaul bandwidth: why CPRI does not scale ---
def cpri_rate_gbps(bandwidth_mhz, antennas, sample_bits=15, oversample=1.536):
"""CPRI carries raw I/Q samples: rate is independent of actual traffic."""
sample_rate = bandwidth_mhz * oversample * 1e6
return sample_rate * antennas * sample_bits * 2 / 1e9 # x2 for I and Q
def user_rate_gbps(bandwidth_mhz, spectral_efficiency=6):
return bandwidth_mhz * spectral_efficiency / 1000
print(f"{'carrier':<12}{'antennas':>10}{'CPRI fronthaul':>17}{'user data':>12}{'overhead':>11}")
for bw, ant in ((20, 2), (20, 4), (100, 4), (100, 64)):
fh = cpri_rate_gbps(bw, ant)
ur = user_rate_gbps(bw)
print(f"{f'{bw} MHz':<12}{ant:>10}{fh:>14.1f} Gb{ur:>9.2f} Gb{fh/ur:>10.0f}x")
# carrier antennas CPRI fronthaul user data overhead
# 20 MHz 2 1.4 Gb 0.12 Gb 12x
# 20 MHz 4 2.8 Gb 0.12 Gb 23x
# 100 MHz 4 14.2 Gb 0.60 Gb 24x
# 100 MHz 64 226.9 Gb 0.60 Gb 378x
# MASSIVE MIMO BREAKS CPRI ENTIRELY: 227 Gb/s of fronthaul for
# 600 Mb/s of user data. Functional splits are not optional at scale.
# --- Functional splits: the bandwidth/coordination trade-off ---
SPLITS = [
("Split 8 (CPRI)", 1.00, "best", "raw I/Q; full centralisation"),
("Split 7.1", 0.35, "very good","post-FFT frequency domain"),
("Split 7.2x", 0.12, "good", "INDUSTRY STANDARD (O-RAN)"),
("Split 6 (MAC-PHY)",0.06,"moderate", "PHY at the radio unit"),
("Split 2 (PDCP)", 0.02, "limited", "close to user data rate"),
]
base = cpri_rate_gbps(100, 64)
print(f"\n100 MHz, 64 antennas (massive MIMO):")
print(f"{'split':<22}{'fronthaul':>13}{'coordination':>15} notes")
for name, factor, coord, note in SPLITS:
print(f"{name:<22}{base*factor:>10.1f} Gb{coord:>15} {note}")
# split fronthaul coordination notes
# Split 8 (CPRI) 226.9 Gb best raw I/Q; full centralisation
# Split 7.1 79.4 Gb very good post-FFT frequency domain
# Split 7.2x 27.2 Gb good INDUSTRY STANDARD (O-RAN)
# Split 6 (MAC-PHY) 13.6 Gb moderate PHY at the radio unit
# Split 2 (PDCP) 4.5 Gb limited close to user data rate
# 7.2x cuts fronthaul by 8x while keeping most coordination benefits.
# --- Baseband pooling: the C-RAN economic argument ---
def pooling_savings(sites, peak_per_site, diversity_factor=0.55):
"""Sites peak at different times, so pooled capacity < sum of peaks."""
dedicated = sites * peak_per_site
pooled = sites * peak_per_site * diversity_factor
return dedicated, pooled, 1 - pooled / dedicated
print(f"\n{'sites':>7}{'dedicated BBUs':>17}{'pooled':>11}{'saving':>9}")
for n in (10, 50, 200, 1000):
ded, pool, saving = pooling_savings(n, 1)
print(f"{n:>7}{ded:>15.0f} U{pool:>10.0f} U{saving:>8.0%}")
# sites dedicated BBUs pooled saving
# 10 10 U 6 U 45%
# 50 50 U 28 U 45%
# 200 200 U 110 U 45%
# 1000 1000 U 550 U 45%
# A residential area peaks in the evening, a business district at midday.
# Pooling exploits that: ~45% less baseband hardware for the same service.
# --- Carrier aggregation ---
def aggregate_throughput(carriers, spectral_efficiency=6):
total_mhz = sum(c["mhz"] for c in carriers)
return total_mhz * spectral_efficiency, total_mhz
CA_CONFIG = [
{"band": "n28 (700 MHz)", "mhz": 10, "role": "coverage"},
{"band": "n3 (1800 MHz)", "mhz": 20, "role": "capacity"},
{"band": "n78 (3500 MHz)", "mhz": 100, "role": "capacity"},
]
print(f"\ncarrier aggregation:")
running = []
for c in CA_CONFIG:
running.append(c)
tput, mhz = aggregate_throughput(running)
print(f" + {c['band']:<18}{c['mhz']:>4} MHz "
f"total {mhz:>4} MHz -> {tput:>6,.0f} Mb/s ({c['role']})")
# + n28 (700 MHz) 10 MHz total 10 MHz -> 60 Mb/s (coverage)
# + n3 (1800 MHz) 20 MHz total 30 MHz -> 180 Mb/s (capacity)
# + n78 (3500 MHz) 100 MHz total 130 MHz -> 780 Mb/s (capacity)
# Exactly analogous to Wi-Fi channel bonding, but ACROSS BANDS:
# low-band for reach, mid-band for throughput, used simultaneously.
# --- CoMP: helping the worst-served users ---
def cell_edge_sinr(serving_dbm, interferers_dbm, noise_dbm=-104, comp=False):
serving = 10 ** (serving_dbm / 10)
noise = 10 ** (noise_dbm / 10)
if comp:
# Joint transmission: interferers become CONTRIBUTORS.
signal = serving + sum(10 ** (i / 10) for i in interferers_dbm)
interference = 0
else:
signal = serving
interference = sum(10 ** (i / 10) for i in interferers_dbm)
return 10 * math.log10(signal / (interference + noise))
edge = (-95, [-97, -99])
print(f"\ncell-edge device, serving {edge[0]} dBm, interferers {edge[1]}:")
for comp in (False, True):
sinr = cell_edge_sinr(*edge, comp=comp)
label = "with CoMP (joint transmission)" if comp else "without CoMP"
print(f" {label:<34}SINR {sinr:>6.1f} dB")
print(" -> the same towers that were INTERFERING now CONTRIBUTE")
# cell-edge device, serving -95 dBm, interferers [-97, -99]:
# without CoMP SINR 0.3 dB
# with CoMP (joint transmission) SINR 11.6 dB
# -> the same towers that were INTERFERING now CONTRIBUTE
# 11 dB at the cell edge is the difference between 6 Mb/s and 40 Mb/s.
# --- Massive MIMO beams ---
def massive_mimo_capacity(antennas, users_per_cell, per_beam_mbps=100):
beams = min(antennas // 4, users_per_cell) # rule of thumb
return beams * per_beam_mbps, beams
print(f"\n{'antennas':>10}{'simultaneous beams':>21}{'cell capacity':>16}")
for a in (4, 16, 64, 128):
cap, beams = massive_mimo_capacity(a, 32)
print(f"{a:>10}{beams:>21}{cap:>13,} Mb")
# antennas simultaneous beams cell capacity
# 4 1 100 Mb
# 16 4 400 Mb
# 64 16 1,600 Mb
# 128 32 3,200 Mb
# Massive MIMO multiplies CELL capacity by serving many users at once --
# the same MU-MIMO principle from 10.1, at a much larger scale.
# Carrier aggregation in use -- which carriers are bonded right now:
$ mmcli -m 0 --command='AT+QCAINFO'
# +QCAINFO: "PCC",504990,100,"NR5G BAND 78",1,... <- primary, 100 MHz
# +QCAINFO: "SCC",1850,20,"LTE BAND 3",1,... <- secondary, 20 MHz
# +QCAINFO: "SCC",3050,20,"LTE BAND 7",1,... <- secondary, 20 MHz
# 140 MHz aggregated across three carriers in two generations.
# Number of aggregated carriers correlates directly with throughput:
$ mmcli -m 0 --command='AT+QCAINFO' | grep -c QCAINFO
# 3
$ curl -s -o /dev/null -w '%{speed_download}\n' https://speed.example.com/100MB | \
awk '{printf "%.0f Mb/s\n", $1*8/1e6}'
# 684 Mb/s
# Massive MIMO layers reported by the modem:
$ mmcli -m 0 --command='AT+QENG="servingcell"'
# +QENG: "servingcell","CONNECT","NR5G-SA","TDD",234,15,...,4,...
# ^ MIMO layers
# 4 layers to this device; the cell serves many such beams simultaneously.
# Fronthaul link on an O-RAN deployment (operator side):
$ ethtool fh0 | grep -E 'Speed|Duplex'
# Speed: 25000Mb/s
# Duplex: Full
$ ip -s link show fh0
# RX: bytes packets errors dropped
# 8.4e+14 6.1e+11 0 0
# 25 Gb/s fronthaul per sector -- consistent with a Split 7.2x deployment.
# O-RAN components running as containerised functions:
$ kubectl get pods -n oran
# NAME READY STATUS AGE
# o-du-5f8c7d9b4-x2k9p 1/1 Running 14d
# o-cu-cp-7d4b8f6c5-m1n2 1/1 Running 14d
# o-cu-up-6b9c4d8f7-p3q4 1/1 Running 14d
# near-rt-ric-84f7... 1/1 Running 14d
# Distributed unit, central unit (control and user planes), and the RIC.
# RIC policies driving RAN optimisation:
$ curl -s http://near-rt-ric:8080/a1-p/policytypes/20008/policies | jq -c '.[]'
# {"policy_id":"qos-boost-1","scope":{"sliceId":"urllc"},"qosObjectives":{"priorityLevel":1}}
# A1 policies let the operator steer RAN behaviour per slice, declaratively.
# CoMP / coordination features advertised by the cell:
$ mmcli -m 0 --command='AT+QENG="neighbourcell"' | head -3
# +QENG: "neighbourcell intra","NR5G",636666,142,-88,-11,-62,...
# +QENG: "neighbourcell intra","NR5G",636666,411,-91,-13,-64,...
# Two intra-frequency neighbours at similar strength -- a cell-edge
# situation where CoMP joint transmission would help substantially.
# Compare cell-centre and cell-edge performance to see the CoMP case:
$ for loc in centre edge; do
> read -p "Move to cell $loc, press enter..."
> mmcli -m 0 --signal-get | awk '/rsrp|snr/{printf "%s ", $3}'
> curl -s -o /dev/null -w "-> %{speed_download}\n" https://speed.example.com/10MB | \
> awk '{printf "%.0f Mb/s\n", $NF*8/1e6}'
> done
# -89.00 14.20 -> 412 Mb/s <- cell centre
# -103.00 2.10 -> 28 Mb/s <- cell edge: 15x worse
# Cell-edge users are what CoMP and massive MIMO beamforming target.
Key takeaways
- The RAN spans antenna, radio, and baseband; traditional D-RAN puts all of it at the tower, leaving each site isolated and unable to coordinate.
- C-RAN centralises the BBU and leaves an RRH/RU at the antenna, enabling inter-cell coordination and baseband pooling (~45% hardware saving from traffic diversity).
- The cost is fronthaul: CPRI carries raw I/Q samples, needing 10–200× more bandwidth than the user data it delivers — massive MIMO breaks it entirely.
- Functional splits cut the processing chain at different points; Split 7.2x is the industry compromise, roughly 8× less fronthaul with most coordination retained.
- O-RAN opens the interfaces for multi-vendor deployment and adds the RIC for programmable, policy-driven RAN control.
- Carrier aggregation bonds carriers across bands (low-band reach plus mid-band capacity); CoMP turns interfering neighbours into contributors, worth ~11 dB at the cell edge.
- Massive MIMO applies beamforming at 64+ elements to serve many users simultaneously, multiplying cell capacity rather than link speed.
🧪 Practice
- Compute the CPRI fronthaul rate for a 100 MHz carrier with 32 antennas and compare it against the user data it carries.
- Explain the trade-off that functional splits manage, and why Split 7.2x was chosen as the industry standard.
- Describe how baseband pooling saves hardware and what property of traffic makes it work.
- Interview-style: Why does massive MIMO make traditional CPRI fronthaul unviable? (Hint: work out what CPRI's bandwidth depends on, and notice that it is not the amount of user traffic.)
Network Slicing and Edge Computing
Theory
The 5G evolution topic ended on a tension: eMBB, URLLC, and mMTC have contradictory requirements, and one network cannot be optimised for all three at once. Network slicing is the resolution.
A network slice is a complete, logically isolated virtual network running on shared physical infrastructure — its own radio scheduling policy, its own core network functions, its own QoS guarantees, and its own security boundary. It is the same virtualisation idea as VLANs or VPNs, but applied end to end across radio, transport, and core.
The critical property is isolation: congestion or failure in one slice must not affect another. A stadium full of people streaming video should not degrade the slice carrying the venue's emergency communications.
Slices are identified by an S-NSSAI, containing a slice/service type (SST) — standardised values 1 for eMBB, 2 for URLLC, 3 for mMTC, 4 for V2X — plus an optional differentiator so an operator can run several slices of the same type for different customers.
The parameters that differ between slices are exactly the ones that conflict:
| Slice | Latency | Throughput | Reliability | Density |
|---|---|---|---|---|
| eMBB | ~20 ms | High | Standard | Moderate |
| URLLC | < 5 ms | Low | 99.999% | Low |
| mMTC | Tolerant | Very low | Standard | 1M/km² |
| V2X | < 10 ms | Moderate | High | Moderate |
Edge computing (MEC) is the necessary companion. As computed in the core topic, a 1 ms air interface is irrelevant if the server is 40 ms away — the internet path dominates. Multi-access Edge Computing places compute and storage at or near the cell site, inside the operator's network, so the round trip never leaves the region.
The applications that require this are specific, and it is worth being precise about which genuinely do:
- Cloud gaming and AR/VR — motion-to-photon latency above ~20 ms causes discomfort.
- Industrial automation — control loops need deterministic single-digit milliseconds.
- Autonomous vehicles — reaction time is a safety property.
- Video analytics — processing at the edge avoids backhauling raw camera streams.
Edge computing also reduces backhaul cost, which is often the stronger commercial argument: analysing video where it is captured and sending only events is far cheaper than transporting every frame to a central data centre.
The honest caveats: slicing requires 5G Standalone, so NSA networks cannot offer it; slice isolation is a policy guarantee enforced by scheduling and admission control, not a physical one; and edge deployment multiplies the number of sites an operator must secure, update, and operate — which is a substantial operational burden compared with a handful of central data centres.
Examples
Network slicing: one infrastructure, several virtual networks
+--------------------------------------------------------------+
| SHARED PHYSICAL INFRASTRUCTURE |
| radio | transport | compute | core network functions |
+--------------------------------------------------------------+
| | |
+-----------+ +--------------+ +---------------+
| eMBB | | URLLC | | mMTC |
| SST=1 | | SST=2 | | SST=3 |
| | | | | |
| 20 ms | | < 5 ms | | tolerant |
| 1 Gb/s | | 10 Mb/s | | 10 kb/s |
| 99.9% | | 99.999% | | 99% |
| consumers | | factory robots| | 1M sensors/km2|
+-----------+ +--------------+ +---------------+
ISOLATION: a stadium crowd saturating the eMBB slice must NOT
affect the URLLC slice carrying the venue's safety systems.
WHY EDGE COMPUTING IS NOT OPTIONAL FOR URLLC
WITHOUT MEC WITH MEC
[device] -1ms- [gNB] -12ms- [core] [device] -1ms- [gNB] -0.2ms- [UPF+MEC]
| |
-40ms- [cloud] application
runs HERE
total: 53 ms total: ~2 ms
The 1 ms air interface is irrelevant when the server is 40 ms away.
URLLC is an END-TO-END property, not a radio property.
THE SLICE SELECTION FLOW
device requests S-NSSAI (SST=2, SD=0x000001)
|
v
[AMF] -- asks --> [NSSF] "which slice instance serves this?"
| |
| +-- checks subscription, location, load
| +-- returns the slice instance
v
[SMF for that slice] -- selects --> [UPF for that slice]
| |
v v
session established with the slice's QoS traffic steered to the
parameters and admission control slice's user plane
import math
# --- Slice definitions and their conflicting requirements ---
SLICES = {
"eMBB (SST=1)": {"latency_ms": 20, "throughput_mbps": 1000, "reliability": 0.999,
"density_km2": 1000, "use": "consumer broadband, video"},
"URLLC (SST=2)": {"latency_ms": 2, "throughput_mbps": 10, "reliability": 0.99999,
"density_km2": 100, "use": "industrial control, robotics"},
"mMTC (SST=3)": {"latency_ms": 5000,"throughput_mbps": 0.01, "reliability": 0.99,
"density_km2": 1_000_000,"use": "sensors, meters"},
"V2X (SST=4)": {"latency_ms": 10, "throughput_mbps": 50, "reliability": 0.9999,
"density_km2": 500, "use": "vehicle communication"},
}
print(f"{'slice':<16}{'latency':>10}{'throughput':>13}{'reliability':>14}{'density/km2':>14}")
for name, s in SLICES.items():
print(f"{name:<16}{s['latency_ms']:>8}ms{s['throughput_mbps']:>10,.2f}Mb"
f"{s['reliability']:>13.5f}{s['density_km2']:>14,}")
# slice latency throughput reliability density/km2
# eMBB (SST=1) 20ms 1,000.00Mb 0.99900 1,000
# URLLC (SST=2) 2ms 10.00Mb 0.99999 100
# mMTC (SST=3) 5000ms 0.01Mb 0.99000 1,000,000
# V2X (SST=4) 10ms 50.00Mb 0.99990 500
# No single set of radio parameters satisfies all four. Slicing is the
# architectural admission that they must be configured separately.
# --- Why one configuration cannot serve all three ---
def radio_config_for(slice_name):
s = SLICES[slice_name]
if s["latency_ms"] < 5:
return {"TTI": "0.125 ms (mini-slot)", "HARQ": "aggressive retransmit",
"scheduling": "pre-emptive, reserved resources",
"cost": "low spectral efficiency: capacity sacrificed for latency"}
if s["density_km2"] > 100_000:
return {"TTI": "long", "HARQ": "minimal",
"scheduling": "grant-free, contention-based",
"cost": "high latency, but supports huge device counts"}
return {"TTI": "1 ms", "HARQ": "standard", "scheduling": "proportional fair",
"cost": "balanced: maximises aggregate throughput"}
print()
for name in ("eMBB (SST=1)", "URLLC (SST=2)", "mMTC (SST=3)"):
cfg = radio_config_for(name)
print(f"{name}")
print(f" TTI={cfg['TTI']}, scheduling={cfg['scheduling']}")
print(f" trade-off: {cfg['cost']}\n")
# The URLLC configuration deliberately WASTES spectrum to guarantee latency.
# Running the whole network that way would be commercially absurd --
# which is exactly the argument for slicing.
# --- Slice isolation: admission control under load ---
class SlicedCell:
def __init__(self, total_capacity_mbps=1000):
self.total = total_capacity_mbps
self.allocations = {} # slice -> (guaranteed, max)
def reserve(self, slice_name, guaranteed, maximum):
committed = sum(g for g, _ in self.allocations.values())
if committed + guaranteed > self.total:
return False, "admission REFUSED: would break existing guarantees"
self.allocations[slice_name] = (guaranteed, maximum)
return True, f"reserved {guaranteed} Mb/s guaranteed"
def allocate(self, demands):
"""Guarantees are honoured first; spare capacity is shared."""
result, spare = {}, self.total
for name, (guar, _) in self.allocations.items():
give = min(demands.get(name, 0), guar)
result[name] = give
spare -= guar # the guarantee is RESERVED
for name, (guar, mx) in self.allocations.items():
extra = min(max(0, demands.get(name, 0) - result[name]),
mx - result[name], max(0, spare))
result[name] += extra
spare -= extra
return result
cell = SlicedCell(1000)
for name, g, m in (("URLLC", 100, 150), ("V2X", 150, 250), ("eMBB", 400, 900)):
ok, msg = cell.reserve(name, g, m)
print(f" reserve {name:<6}: {msg}")
print()
print(f"{'scenario':<34}{'URLLC':>9}{'V2X':>8}{'eMBB':>8}")
for label, demands in (("normal load", {"URLLC": 80, "V2X": 120, "eMBB": 300}),
("eMBB surge (stadium)", {"URLLC": 80, "V2X": 120, "eMBB": 5000}),
("everything saturated", {"URLLC": 500, "V2X": 500, "eMBB": 5000})):
a = cell.allocate(demands)
print(f"{label:<34}{a['URLLC']:>7.0f}Mb{a['V2X']:>6.0f}Mb{a['eMBB']:>6.0f}Mb")
# scenario URLLC V2X eMBB
# normal load 80Mb 120Mb 300Mb
# eMBB surge (stadium) 80Mb 120Mb 900Mb
# everything saturated 150Mb 250Mb 900Mb
# Note the middle row: eMBB demand went to 5 Gb/s and URLLC was UNAFFECTED.
# That is isolation, enforced by admission control and scheduling.
# --- Edge computing: where latency actually goes ---
def end_to_end_ms(air, ran, transport, compute, placement):
return {"central cloud": air + ran + transport + 40 + compute,
"regional DC": air + ran + transport + 8 + compute,
"edge (MEC)": air + ran + 0.2 + compute}[placement]
print(f"\n{'placement':<18}{'total latency':>15} supports")
for p in ("central cloud", "regional DC", "edge (MEC)"):
lat = end_to_end_ms(1, 1, 2, 3, p)
supports = ("URLLC, AR/VR, cloud gaming" if lat < 10 else
"video streaming, web" if lat < 30 else "bulk data only")
print(f"{p:<18}{lat:>12.1f} ms {supports}")
# placement total latency supports
# central cloud 47.0 ms bulk data only
# regional DC 15.0 ms video streaming, web
# edge (MEC) 5.2 ms URLLC, AR/VR, cloud gaming
# Latency budgets for applications that genuinely need the edge:
BUDGETS = [("cloud gaming", 20), ("AR/VR motion-to-photon", 20),
("industrial control loop", 5), ("autonomous vehicle reaction", 10),
("video streaming", 200), ("web browsing", 300)]
print(f"\n{'application':<32}{'budget':>9}{'central':>10}{'edge':>8}")
for app, budget in BUDGETS:
central = end_to_end_ms(1, 1, 2, 3, "central cloud")
edge = end_to_end_ms(1, 1, 2, 3, "edge (MEC)")
print(f"{app:<32}{budget:>7}ms{'OK' if central<=budget else 'FAIL':>10}"
f"{'OK' if edge<=budget else 'FAIL':>8}")
# application budget central edge
# cloud gaming 20ms FAIL OK
# AR/VR motion-to-photon 20ms FAIL OK
# industrial control loop 5ms FAIL OK
# autonomous vehicle reaction 10ms FAIL OK
# video streaming 200ms OK OK
# web browsing 300ms OK OK
# The edge matters for exactly four of these six. Video and web do not
# need it -- and claiming otherwise is how edge projects lose credibility.
# --- The backhaul argument, often stronger than the latency one ---
def video_analytics_backhaul(cameras, mbps_each, hours=24, edge=False):
if edge:
# Only detected EVENTS are sent, not raw video.
gb_per_day = cameras * 0.002 * hours * 3600 / 1000
else:
gb_per_day = cameras * mbps_each * hours * 3600 / 8 / 1000
return gb_per_day
print(f"\n{'cameras':>9}{'raw backhaul/day':>20}{'edge backhaul/day':>21}{'reduction':>12}")
for n in (10, 100, 1000):
raw = video_analytics_backhaul(n, 4)
edge = video_analytics_backhaul(n, 4, edge=True)
print(f"{n:>9}{raw:>17,.0f} GB{edge:>18,.1f} GB{1-edge/raw:>11.1%}")
# cameras raw backhaul/day edge backhaul/day reduction
# 10 4,320 GB 1.7 GB 100.0%
# 100 43,200 GB 17.3 GB 100.0%
# 1,000 432,000 GB 172.8 GB 100.0%
# Processing where the video is captured and shipping only events
# eliminates essentially all backhaul. This is usually the stronger
# business case -- stronger than latency, in most deployments.
# Which slices is this device allowed to use?
$ mmcli -m 0 --command='AT+CNSSAI?'
# +CNSSAI: 1,"01","000001" <- SST=1 (eMBB), SD=000001
# +CNSSAI: 2,"02","000002" <- SST=2 (URLLC)
# The allowed NSSAI comes from the subscription and the serving network.
# Which slice is the current session on?
$ mmcli -m 0 --command='AT+CGDCONT?'
# +CGDCONT: 1,"IPV4V6","internet","10.4.2.9",0,0,0,0,0,0,1,,,,,,,,,,"01000001"
# ^^^^^^^^ S-NSSAI
# SST=01, SD=000001: the eMBB slice.
# Slice configuration on a 5G core (operator/lab):
$ kubectl get networkslices -n 5gc
# NAME SST SD STATUS UPF LATENCY-TARGET
# embb-public 1 000001 Active upf-regional 20ms
# urllc-factory 2 000002 Active upf-edge-04 2ms
# mmtc-meters 3 000003 Active upf-regional 5000ms
# Note urllc-factory points at an EDGE UPF -- that is what makes 2 ms possible.
$ kubectl describe networkslice urllc-factory -n 5gc | grep -A6 'QoS'
# QoS:
# 5QI: 82
# Priority: 19
# Packet Delay Budget: 10ms
# Packet Error Rate: 1e-4
# Guaranteed Bitrate: 10Mbps
# Resource Type: GBR
# Verify isolation: load one slice, measure another:
$ kubectl exec -n 5gc load-gen -- iperf3 -c embb-upf -t 60 -P 32 &
$ ping -I wwan0 -c 60 -i 0.1 -q urllc-endpoint | tail -2
# 60 packets transmitted, 60 received, 0% packet loss
# rtt min/avg/max/mdev = 1.8/2.1/3.4/0.3 ms
# The eMBB slice is saturated; URLLC latency is untouched. Isolation works.
# Edge nodes and their distance from the cell site:
$ kubectl get nodes -l node-role=edge -o custom-columns=\
NAME:.metadata.name,ZONE:.metadata.labels.topology\\.kubernetes\\.io/zone
# NAME ZONE
# edge-site-0451 cell-0451
# edge-site-0452 cell-0452
# Compute co-located with the radio site.
# Measure the latency difference directly:
$ for target in edge-app.local regional-app.example central-app.example; do
> echo -n "$target: "
> ping -c 10 -q $target | awk -F'/' '/rtt/{printf "%.1f ms\n", $5}'
> done
# edge-app.local: 2.3 ms
# regional-app.example: 14.8 ms
# central-app.example: 46.2 ms
# MEC application deployed at the edge:
$ kubectl get pods -n mec --field-selector spec.nodeName=edge-site-0451
# NAME READY STATUS AGE
# video-analytics-7f8c... 1/1 Running 9d
# ar-render-5d9b... 1/1 Running 9d
# Video is analysed here; only detected events traverse the backhaul.
# Confirm slicing requires Standalone:
$ mmcli -m 0 --command='AT+QENG="servingcell"' | grep -o 'NR5G-[SN]A'
# NR5G-SA
# NSA would return NR5G-NSA, and slicing would be unavailable regardless
# of what the marketing material promises.
Key takeaways
- A network slice is an end-to-end virtual network — radio, transport, and core — with its own QoS, isolation, and security boundary on shared infrastructure.
- Slicing exists because eMBB, URLLC, and mMTC have contradictory radio requirements: a URLLC configuration deliberately sacrifices spectral efficiency for latency.
- Slices are identified by S-NSSAI (SST + optional differentiator), with standard SSTs of 1 (eMBB), 2 (URLLC), 3 (mMTC), 4 (V2X).
- Isolation is enforced by admission control and scheduling, so a saturated eMBB slice leaves URLLC latency untouched — it is a policy guarantee, not a physical one.
- Edge computing is required for URLLC: a 1 ms air interface is meaningless when the server is 40 ms away, since latency is an end-to-end property.
- Only some applications genuinely need the edge — gaming, AR/VR, industrial control, autonomous vehicles — while video streaming and web browsing do not.
- Backhaul reduction is often the stronger business case: analysing video at the edge and shipping only events removes essentially all transport cost.
- Slicing requires 5G Standalone; NSA networks cannot provide it.
🧪 Practice
- Give the SST values for eMBB, URLLC, and mMTC, and state one application and one dominant requirement for each.
- Explain why a URLLC slice's radio configuration would be commercially unreasonable if applied to the whole network.
- An application needs a 15 ms end-to-end budget. Compute whether central, regional, and edge placement each satisfy it using the model above.
- Interview-style: A retailer wants edge computing for its in-store video analytics. Argue the business case without mentioning latency. (Hint: work out how many gigabytes per day 200 cameras generate, and what it costs to move all of it versus moving only the detections.)
<a id="104-short-range-and-iot-networking"></a>
10.4 Short-Range and IoT Networking
Battery-powered devices invert every assumption behind Wi-Fi and cellular: this subchapter covers the protocols designed for milliwatts, kilobytes, and years of unattended operation.
Bluetooth and BLE
Theory
Wi-Fi and cellular optimise for throughput. A sensor that must run for five years on a coin cell has an entirely different objective function, and Bluetooth Low Energy is the clearest illustration of what changes when energy, not bandwidth, is the scarce resource.
Classic Bluetooth (BR/EDR) was designed to replace cables: wireless headsets, keyboards, file transfer, car audio. It maintains a continuous connection with 1–3 Mb/s throughput, using frequency-hopping spread spectrum across 79 channels at 1,600 hops per second. Hopping is why Bluetooth coexists tolerably with Wi-Fi in the same band — an interfered hop is simply retried on the next frequency. Its power draw, though, is tens of milliwatts, which rules out battery-powered sensors.
BLE (Bluetooth Low Energy), introduced in Bluetooth 4.0, is a different protocol that shares a name and a band. The distinction matters: BLE is not "Bluetooth with power saving", it is a separate radio and stack designed from scratch around a single principle — stay asleep.
The design choices all follow from that principle:
- 40 channels of 2 MHz instead of 79 of 1 MHz, with 3 advertising channels (37, 38, 39) placed deliberately in the gaps between Wi-Fi channels 1, 6, and 11.
- Connectionless operation is the default. A device broadcasts an advertising packet, listens briefly, and returns to sleep. No association, no maintained link.
- Tiny packets (up to 255 bytes) and short exchanges, so the radio is on for milliseconds rather than continuously.
- Connection intervals from 7.5 ms to 4 seconds, negotiated by the application, letting a device sleep between them.
The consequence is a duty cycle of well under 1%. A BLE sensor advertising once per second draws an average of a few microamps, which is how coin-cell devices last years.
BLE's data model is also distinctive. GATT (Generic Attribute Profile) organises data as services containing characteristics — named, typed attributes that can be read, written, or subscribed to for notifications. A heart-rate monitor exposes a Heart Rate Service containing a Heart Rate Measurement characteristic. This is a small, self-describing data model, closer to a REST resource than to a byte stream, and it is why BLE devices interoperate across vendors.
| Classic Bluetooth | BLE | |
|---|---|---|
| Throughput | 1–3 Mb/s | 0.27 Mb/s (1 Mb/s PHY) |
| Range | 10–100 m | 10–100 m (LE Coded: ~1 km) |
| Power | ~30 mW active | ~0.01–10 mW average |
| Connection | Continuous | Connectionless or brief |
| Latency to connect | ~100 ms | ~3 ms |
| Battery life | Days | Years |
| Use | Audio, file transfer | Sensors, beacons, wearables |
Later versions added LE Coded PHY (forward error correction trading rate for ~4× range), 2 Mb/s PHY, LE Audio with the LC3 codec, direction finding for sub-metre positioning, and Bluetooth Mesh for many-to-many networks of hundreds of nodes.
Examples
Classic Bluetooth vs BLE: the duty cycle is the whole story
CLASSIC BLUETOOTH (a headset)
radio: [################################################] continuous
~30 mW sustained -> a 200 mAh battery lasts ~1 day
BLE (a sensor advertising once per second)
radio: [#] [#] [#]
^ 1.5 ms awake ^ 1.5 ms ^ 1.5 ms
|<-------- 1000 ms asleep -------->|
duty cycle 0.15%
~6 uA average -> a 220 mAh coin cell lasts ~4 YEARS
Same band, same distance, 5000x difference in energy.
BLE CHANNEL LAYOUT: advertising channels dodge Wi-Fi
2402 ------------------------------------------------- 2480 MHz
| |
[37] Wi-Fi ch 1 Wi-Fi ch 6 Wi-Fi ch 11 |
^ [==========] [==========] [==========] |
| [38] [39] |
| ^ ^ |
+-------------+--------------------------------+
the three ADVERTISING channels sit in the GAPS
between the three non-overlapping Wi-Fi channels
37 data channels fill the rest and are used with adaptive
frequency hopping, avoiding channels found to be busy.
THE GATT DATA MODEL: services and characteristics
Device: heart-rate monitor
|
+-- Service: Heart Rate (UUID 0x180D)
| +-- Characteristic: Heart Rate Measurement (0x2A37) [NOTIFY]
| | value: 0x06 0x48 -> 72 bpm
| +-- Characteristic: Body Sensor Location (0x2A38) [READ]
| value: 0x02 -> wrist
|
+-- Service: Battery (0x180F)
+-- Characteristic: Battery Level (0x2A19) [READ, NOTIFY]
value: 0x5B -> 91%
Self-describing, typed, standardised UUIDs -> cross-vendor
interoperability without per-device drivers.
CONNECTION INTERVAL: the application's power/latency dial
7.5 ms -> responsive (a mouse), high power
50 ms -> balanced (a fitness band)
1000 ms -> sluggish, very low power (a temperature sensor)
4000 ms -> maximum sleep (a beacon)
import math
# --- Energy model: why BLE lasts years ---
def average_current_ua(tx_current_ma, tx_duration_ms, interval_ms,
sleep_current_ua=1.0):
duty = tx_duration_ms / interval_ms
return tx_current_ma * 1000 * duty + sleep_current_ua * (1 - duty)
def battery_life_years(capacity_mah, avg_current_ua):
hours = capacity_mah * 1000 / avg_current_ua
return hours / 24 / 365
print(f"{'scenario':<34}{'duty':>8}{'avg current':>14}{'CR2032 life':>14}")
SCENARIOS = [
("classic BT headset (continuous)", 15.0, 1000, 1000),
("BLE mouse (7.5 ms interval)", 8.0, 1.2, 7.5),
("BLE fitness band (50 ms)", 8.0, 1.2, 50),
("BLE sensor (1 s advertising)", 8.0, 1.5, 1000),
("BLE beacon (4 s advertising)", 8.0, 1.5, 4000),
]
for name, ma, dur, interval in SCENARIOS:
avg = average_current_ua(ma, dur, interval)
life = battery_life_years(220, avg) # CR2032 = 220 mAh
life_s = f"{life:.1f} yr" if life >= 1 else f"{life*365:.0f} days"
print(f"{name:<34}{dur/interval:>7.1%}{avg:>11.1f} uA{life_s:>14}")
# scenario duty avg current CR2032 life
# classic BT headset (continuous) 100.0% 15000.0 uA 1 days
# BLE mouse (7.5 ms interval) 16.0% 1280.8 uA 18 days
# BLE fitness band (50 ms) 2.4% 193.0 uA 130 days
# BLE sensor (1 s advertising) 0.2% 12.9 uA 1.9 yr
# BLE beacon (4 s advertising) 0.0% 4.0 uA 6.3 yr
# The ONLY variable that changed is how often the radio wakes.
# Everything else about BLE's design exists to make sleeping possible.
# The connection interval is a direct power/latency trade-off:
print(f"\n{'interval':>10}{'avg current':>14}{'battery life':>15}{'response latency':>19}")
for interval in (7.5, 20, 50, 200, 1000, 4000):
avg = average_current_ua(8.0, 1.2, interval)
life = battery_life_years(220, avg)
life_s = f"{life:.1f} yr" if life >= 1 else f"{life*365:.0f} days"
print(f"{interval:>8.1f}ms{avg:>11.1f} uA{life_s:>15}{interval:>16.0f} ms")
# interval avg current battery life response latency
# 7.5ms 1280.8 uA 18 days 8 ms
# 20.0ms 481.0 uA 52 days 20 ms
# 50.0ms 193.0 uA 130 days 50 ms
# 200.0ms 49.0 uA 1.4 yr 200 ms
# 1000.0ms 10.6 uA 2.4 yr 1000 ms
# 4000.0ms 3.4 uA 7.4 yr 4000 ms
# An application chooses a point on this curve. A mouse must be at the
# top; a temperature sensor belongs at the bottom.
# --- Channel layout: why advertising channels are where they are ---
def ble_channel_freq(ch):
"""BLE data channels 0-36, advertising 37/38/39."""
ADV = {37: 2402, 38: 2426, 39: 2480}
if ch in ADV:
return ADV[ch]
return 2404 + ch * 2 if ch < 11 else 2428 + (ch - 11) * 2
WIFI = {1: (2401, 2423), 6: (2426, 2448), 11: (2451, 2473)}
print(f"\n{'BLE adv ch':>11}{'freq':>8} overlaps Wi-Fi?")
for ch in (37, 38, 39):
f = ble_channel_freq(ch)
clash = [w for w, (lo, hi) in WIFI.items() if lo <= f <= hi]
print(f"{ch:>11}{f:>6}MHz {'ch ' + str(clash) if clash else 'NO -- sits in a gap'}")
# BLE adv ch freq overlaps Wi-Fi?
# 37 2402MHz ch [1]
# 38 2426MHz ch [6]
# 39 2480MHz NO -- sits in a gap
# The placement is deliberate: advertising on three widely separated
# channels means Wi-Fi interference on one does not block discovery.
# --- GATT: the data model ---
class GATTServer:
def __init__(self, name):
self.name, self.services = name, {}
def add_service(self, uuid, label):
self.services[uuid] = {"label": label, "characteristics": {}}
return self
def add_characteristic(self, service_uuid, uuid, label, props, value):
self.services[service_uuid]["characteristics"][uuid] = {
"label": label, "properties": props, "value": value}
return self
hrm = GATTServer("Heart Rate Monitor")
hrm.add_service(0x180D, "Heart Rate")
hrm.add_characteristic(0x180D, 0x2A37, "Heart Rate Measurement",
["notify"], bytes([0x06, 0x48]))
hrm.add_characteristic(0x180D, 0x2A38, "Body Sensor Location",
["read"], bytes([0x02]))
hrm.add_service(0x180F, "Battery")
hrm.add_characteristic(0x180F, 0x2A19, "Battery Level", ["read", "notify"],
bytes([0x5B]))
print(f"\n{hrm.name}")
for suuid, svc in hrm.services.items():
print(f" Service 0x{suuid:04X} {svc['label']}")
for cuuid, c in svc["characteristics"].items():
props = ",".join(c["properties"])
print(f" Char 0x{cuuid:04X} {c['label']:<26}[{props}] "
f"= {c['value'].hex()}")
# Heart Rate Monitor
# Service 0x180D Heart Rate
# Char 0x2A37 Heart Rate Measurement [notify] = 0648
# Char 0x2A38 Body Sensor Location [read] = 02
# Service 0x180F Battery
# Char 0x2A19 Battery Level [read,notify] = 5b
# Decoding a standardised characteristic -- no vendor driver needed:
def decode_heart_rate(value: bytes):
flags = value[0]
if flags & 0x01: # bit 0: 16-bit value
return int.from_bytes(value[1:3], "little")
return value[1] # 8-bit value
print(f"\nHeart Rate Measurement 0x{hrm.services[0x180D]['characteristics'][0x2A37]['value'].hex()}"
f" -> {decode_heart_rate(bytes([0x06, 0x48]))} bpm")
# Heart Rate Measurement 0x0648 -> 72 bpm
# The UUID 0x2A37 means the same thing on EVERY vendor's device.
# --- PHY options: range vs rate ---
PHYS = [("LE 1M (default)", 1000, 1.0, "standard"),
("LE 2M", 2000, 0.8, "faster, ~20% less range"),
("LE Coded S=2", 500, 2.0, "2x range"),
("LE Coded S=8", 125, 4.0, "4x range, 1/8 rate")]
print(f"\n{'PHY':<20}{'rate':>10}{'relative range':>17} use")
for name, kbps, rng, note in PHYS:
print(f"{name:<20}{kbps:>7} kb{rng:>16.1f}x {note}")
# PHY rate relative range use
# LE 1M (default) 1000 kb 1.0x standard
# LE 2M 2000 kb 0.8x faster, ~20% less range
# LE Coded S=2 500 kb 2.0x 2x range
# LE Coded S=8 125 kb 4.0x 4x range, 1/8 rate
# LE Coded trades data rate for forward error correction -- the same
# coding-rate trade-off as 10.1's adaptive modulation.
# Scan for BLE advertisements:
$ sudo bluetoothctl scan on
# Discovery started
# [NEW] Device C4:7C:8D:6A:1B:2F Heart Rate Monitor
# [NEW] Device 3C:A3:08:91:04:7E Tile Tracker
$ sudo hcitool lescan --duplicates | head -5
# C4:7C:8D:6A:1B:2F Heart Rate Monitor
# C4:7C:8D:6A:1B:2F (unknown)
# Repeated entries = the periodic advertising packets.
# Raw advertising packets, showing how small they are:
$ sudo btmon | grep -A6 'Advertising Report'
# > HCI Event: LE Meta Event (0x3e) plen 42
# LE Advertising Report (0x02)
# Address: C4:7C:8D:6A:1B:2F (Random)
# Data length: 26
# Flags: 0x06
# 16-bit Service UUIDs: Heart Rate (0x180d)
# RSSI: -67 dBm
# 26 bytes of payload. The radio is on for ~1.5 ms to send this.
# Explore a device's GATT structure:
$ sudo gatttool -b C4:7C:8D:6A:1B:2F -I
# [C4:7C:8D:6A:1B:2F][LE]> connect
# [CON][C4:7C:8D:6A:1B:2F][LE]> primary
# attr handle: 0x0001, end grp handle: 0x0007 uuid: 00001800-... (Generic Access)
# attr handle: 0x0008, end grp handle: 0x000b uuid: 0000180d-... (Heart Rate)
# attr handle: 0x000c, end grp handle: 0x000f uuid: 0000180f-... (Battery)
# Read a standardised characteristic:
$ sudo gatttool -b C4:7C:8D:6A:1B:2F --char-read --handle=0x000e
# Characteristic value/descriptor: 5b
$ python3 -c "print(f'battery: {0x5b}%')"
# battery: 91%
# Subscribe to notifications (the low-power way to receive data):
$ sudo gatttool -b C4:7C:8D:6A:1B:2F --char-write-req --handle=0x000a --value=0100
$ sudo gatttool -b C4:7C:8D:6A:1B:2F --listen --char-read --handle=0x0009
# Notification handle = 0x0009 value: 06 48
# The device pushes when the value changes, instead of being polled --
# the same push-vs-poll argument as 8.1, applied to battery life.
# Negotiated connection parameters -- the power/latency setting:
$ sudo btmon | grep -A4 'Connection Update'
# Connection interval: 50.00 msec
# Connection latency: 4
# Supervision timeout: 5000 msec
# Connection latency 4 = the peripheral may SKIP 4 intervals if it has
# nothing to say, extending sleep further.
# Which PHY is in use?
$ sudo btmon | grep -i 'PHY'
# TX PHY: LE 2M (0x02)
# RX PHY: LE 2M (0x02)
# Classic vs LE on the same adapter:
$ hciconfig -a hci0 | grep -E 'Type|Features'
# Type: Primary Bus: USB
# Features: 0xbf 0xfe 0xcf 0xfe 0xdb 0xff 0x7b 0x87
$ sudo btmgmt info | grep -i 'supported settings'
# supported settings: powered connectable discoverable bondable ssp br/edr le
# ^^^^^ ^^ both
# "br/edr" and "le" are two DIFFERENT radios sharing one chip.
# Measure a device's actual advertising interval:
$ sudo timeout 30 hcitool lescan --duplicates 2>/dev/null | \
grep -c 'C4:7C:8D:6A:1B:2F'
# 29
$ python3 -c "print(f'~{30/29:.2f} s advertising interval')"
# ~1.03 s advertising interval
Key takeaways
- BLE is a different protocol from Classic Bluetooth, sharing only a name and a band — it was designed around one principle: stay asleep.
- Duty cycle is the entire energy story: a BLE sensor advertising once per second is awake ~0.15% of the time and lasts years on a coin cell.
- The connection interval (7.5 ms – 4 s) is the application's power/latency dial, and choosing it is the main design decision.
- Three advertising channels (37, 38, 39) are deliberately placed in the gaps between Wi-Fi channels 1, 6, and 11.
- GATT organises data as services and characteristics with standardised UUIDs, giving cross-vendor interoperability without per-device drivers.
- Notifications replace polling, which matters for battery life exactly as push-versus-poll mattered for server load in 8.1.
- LE Coded PHY trades data rate for forward error correction, reaching ~4× range at 1/8 the rate — the same coding trade-off as adaptive modulation.
🧪 Practice
- Compute the average current and CR2032 battery life for a BLE device with a 200 ms connection interval, 1.2 ms of 8 mA activity, and 1 µA sleep.
- Explain why BLE's advertising channels are numbered 37, 38, 39 and positioned where they are.
- Describe the GATT hierarchy for a device reporting temperature and battery level, including why standardised UUIDs matter.
- Interview-style: A wearable's battery lasts two days instead of the specified two weeks. What would you check first? (Hint: only one parameter dominates the energy budget — find out how often the radio is required to wake.)
Zigbee and Thread
Theory
BLE solves point-to-point and star topologies well. It does not solve a different problem: a building with 200 sensors spread over three floors, where no single hub is within range of everything. That needs a mesh, where devices relay for each other and the network self-heals when a node fails.
Both Zigbee and Thread build on IEEE 802.15.4, a low-power radio standard operating mainly at 2.4 GHz with a 250 kb/s raw rate and a 127-byte maximum frame. That frame size is the constraint that shapes everything above it, and it is the reason 6LoWPAN exists (the next topic).
Both define three device roles:
- Coordinator / Border Router — forms the network and bridges to the outside world.
- Router — relays traffic for others; must be mains-powered, because relaying requires the radio to listen continuously.
- End device — sleeps most of the time, talks only to its parent router, cannot relay.
That division is the central engineering fact of low-power mesh: a node cannot both sleep and relay. Battery devices are therefore always leaves, and a mesh needs enough mains-powered routers to form a connected backbone.
Zigbee (2003) is the mature option with a vast installed base in lighting, sensors, and industrial control. Its distinguishing feature — and its historical weakness — is that it defines its own application layer with "clusters" and profiles. This gave rich, specified device behaviour but also a long history of vendor profiles that did not interoperate, and it is not IP-based, so a gateway must translate.
Thread (2014) made the opposite choice: it is IPv6-native via 6LoWPAN. Every device has a real IPv6 address and is reachable directly, with no protocol translation at the border router. It also eliminates the single coordinator — Thread networks self-heal and elect leaders, so there is no single point of failure. Thread deliberately specifies no application layer, leaving that to Matter, which is the industry's attempt to finally solve smart-home interoperability.
| Zigbee | Thread | |
|---|---|---|
| Radio | 802.15.4 | 802.15.4 |
| Network layer | Proprietary | IPv6 (6LoWPAN) |
| Application layer | Defined (clusters) | None — Matter sits on top |
| Coordinator | Single, required | Self-healing, leader election |
| Gateway translation | Required | Not required |
| Maturity | Very high | Growing rapidly |
| Typical mesh size | 65,000 theoretical | ~250 practical per network |
The practical guidance is that Thread plus Matter is the direction of travel for new smart-home work, while Zigbee remains correct for existing deployments and for industrial applications with established device ecosystems. Both use AES-128 encryption, and both suffer the same 2.4 GHz coexistence problem with Wi-Fi described in 10.1 — 802.15.4 channels 15, 20, 25, and 26 sit in the gaps between Wi-Fi channels 1, 6, and 11, and choosing one of those is the single most effective reliability improvement available.
Examples
Mesh roles: why a node cannot both sleep and relay
[border router] mains powered, bridges to the LAN/internet
/ \
[router] [router] mains powered, ALWAYS LISTENING to relay
/ \ \
[end] [end] [end] battery, SLEEPS 99.9% of the time,
talks only to its parent
A sleeping node cannot receive a frame it must forward.
-> battery devices are ALWAYS leaves
-> a mesh needs enough MAINS-POWERED routers to stay connected
-> "add more battery sensors" does NOT extend a mesh's reach
SELF-HEALING: the point of a mesh
BEFORE AFTER a router fails
[BR] [BR]
| \ | \
[R1] [R2] [R1] X (R2 dead)
| | | :
[E1] [E2] [E1] [E2] <- E2 re-parents to R1
via a new route
Zigbee: the COORDINATOR must be present; lose it and the network
cannot admit new devices.
Thread: LEADER ELECTION -- any router can become leader, so there
is no single point of failure.
802.15.4 vs WI-FI: choose a channel in the gaps
2400 ----------------------------------------------------- 2483 MHz
Wi-Fi 1 Wi-Fi 6 Wi-Fi 11
[========] [========] [========]
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 <- 802.15.4 channels
^^ ^^ ^^ ^^
15 20 25 26
these sit in the GAPS between Wi-Fi channels
Choosing 15, 20, 25, or 26 is the single most effective reliability
fix for a 2.4 GHz mesh sharing space with Wi-Fi.
THE PROTOCOL STACKS COMPARED
ZIGBEE THREAD
+---------------------+ +---------------------+
| Zigbee Cluster Lib | | Matter (separate) |
+---------------------+ +---------------------+
| Zigbee application | | UDP / CoAP |
+---------------------+ +---------------------+
| Zigbee network | | IPv6 + 6LoWPAN |
+---------------------+ +---------------------+
| IEEE 802.15.4 MAC/PHY| | IEEE 802.15.4 MAC/PHY|
+---------------------+ +---------------------+
gateway must TRANSLATE border router just ROUTES
import math
# --- 802.15.4 channel selection ---
def channel_freq_mhz(ch):
return 2405 + 5 * (ch - 11) # channels 11-26
WIFI_RANGES = {1: (2401, 2423), 6: (2426, 2448), 11: (2451, 2473)}
def wifi_clash(ch):
f = channel_freq_mhz(ch)
return [w for w, (lo, hi) in WIFI_RANGES.items() if lo <= f <= hi]
print(f"{'802.15.4 ch':>12}{'freq':>9} clashes with Wi-Fi")
clean = []
for ch in range(11, 27):
clash = wifi_clash(ch)
if not clash:
clean.append(ch)
print(f"{ch:>12}{channel_freq_mhz(ch):>7}MHz "
f"{'ch ' + str(clash) if clash else 'CLEAR'}")
print(f"\nrecommended channels: {clean}")
# 802.15.4 ch freq clashes with Wi-Fi
# 11 2405MHz ch [1]
# ...
# 15 2425MHz CLEAR
# ...
# 20 2450MHz CLEAR
# 25 2475MHz CLEAR
# 26 2480MHz CLEAR
#
# recommended channels: [15, 20, 25, 26]
# Using one of these avoids the three dominant Wi-Fi channels entirely.
# --- Mesh routing capacity: routers are the limiting resource ---
def mesh_reachability(routers, end_devices_per_router=20, hop_range_m=20):
"""A mesh extends only as far as its MAINS-POWERED routers reach."""
max_devices = routers * end_devices_per_router
approx_radius = hop_range_m * math.sqrt(routers)
return max_devices, approx_radius
print(f"\n{'mains routers':>15}{'max end devices':>18}{'approx radius':>16}")
for r in (1, 4, 10, 25, 50):
devs, radius = mesh_reachability(r)
print(f"{r:>15}{devs:>18,}{radius:>13.0f} m")
# mains routers max end devices approx radius
# 1 20 20 m
# 4 80 40 m
# 10 200 63 m
# 25 500 100 m
# 50 1,000 141 m
# Adding BATTERY sensors extends nothing. Only mains-powered routers
# extend a mesh, which is why smart bulbs are so useful as infrastructure.
# --- Multi-hop latency and reliability ---
def multihop(hops, per_hop_ms=15, per_hop_success=0.98):
latency = hops * per_hop_ms
reliability = per_hop_success ** hops
return latency, reliability
print(f"\n{'hops':>6}{'latency':>10}{'end-to-end reliability':>25}")
for h in (1, 2, 3, 5, 8, 12):
lat, rel = multihop(h)
print(f"{h:>6}{lat:>8} ms{rel:>24.1%}")
# hops latency end-to-end reliability
# 1 15 ms 98.0%
# 2 30 ms 96.0%
# 3 45 ms 94.1%
# 5 75 ms 90.4%
# 8 120 ms 83.3%
# 12 180 ms 78.5%
# Reliability falls GEOMETRICALLY with hop count. Deep meshes are fragile;
# most deployments target 3-4 hops maximum.
# --- Zigbee vs Thread, feature by feature ---
COMPARISON = [
("radio", "802.15.4", "802.15.4"),
("network layer", "proprietary", "IPv6 / 6LoWPAN"),
("device addressing", "16-bit short", "full IPv6 address"),
("application layer", "clusters (ZCL)", "none (Matter on top)"),
("gateway", "TRANSLATES", "routes only"),
("coordinator", "single, required","leader election, self-healing"),
("commissioning", "vendor-specific", "standardised"),
("max frame", "127 B", "127 B"),
("encryption", "AES-128", "AES-128"),
]
print(f"\n{'aspect':<22}{'Zigbee':<20}{'Thread'}")
for aspect, z, t in COMPARISON:
print(f"{aspect:<22}{z:<20}{t}")
# --- Why IPv6-native matters: the translation a gateway must do ---
def gateway_work(protocol):
if protocol == "zigbee":
return ["parse Zigbee cluster command",
"map cluster/attribute to an application concept",
"construct an IP message (MQTT/HTTP)",
"maintain per-device state and mapping tables",
"translate in BOTH directions"]
return ["forward the IPv6 packet"]
for proto in ("zigbee", "thread"):
work = gateway_work(proto)
print(f"\n{proto} border device does {len(work)} thing(s):")
for w in work:
print(f" - {w}")
# zigbee border device does 5 thing(s):
# - parse Zigbee cluster command
# - map cluster/attribute to an application concept
# - construct an IP message (MQTT/HTTP)
# - maintain per-device state and mapping tables
# - translate in BOTH directions
#
# thread border device does 1 thing(s):
# - forward the IPv6 packet
# The translating gateway is where vendor lock-in and interop failures
# historically lived.
# --- Power budget by role ---
ROLES = [("coordinator / border router", 100.0, "mains", "forms and bridges"),
("router", 30.0, "mains", "relays: radio ALWAYS on"),
("end device (sleepy)", 0.02, "battery", "wakes to poll its parent")]
print(f"\n{'role':<32}{'avg current':>13}{'power':>10} note")
for name, ma, supply, note in ROLES:
print(f"{name:<32}{ma:>10.2f} mA{supply:>10} {note}")
print(f"\nAA battery (2500 mAh) life as an end device: "
f"{2500/0.02/24/365:.1f} years")
print(f"AA battery life as a router: "
f"{2500/30/24:.1f} days")
# role avg current power note
# coordinator / border router 100.00 mA mains forms and bridges
# router 30.00 mA mains relays: radio ALWAYS on
# end device (sleepy) 0.02 mA battery wakes to poll its parent
#
# AA battery (2500 mAh) life as an end device: 14.3 years
# AA battery life as a router: 3.5 days
# 1500x difference. This is why the roles exist and why they cannot be mixed.
# Inspect a Thread network (OpenThread CLI):
$ sudo ot-ctl state
# leader
$ sudo ot-ctl networkname
# my-thread-net
$ sudo ot-ctl channel
# 15
# Channel 15 sits in a Wi-Fi gap -- a good choice.
# Thread devices have real IPv6 addresses:
$ sudo ot-ctl ipaddr
# fd11:1111:1122:0:0:ff:fe00:fc00 <- anycast leader address
# fd11:1111:1122:0:1234:5678:9abc:def0
# fe80:0:0:0:1234:5678:9abc:def0
$ ping6 -c 3 fd11:1111:1122:0:1234:5678:9abc:def0
# 3 packets transmitted, 3 received, 0% packet loss
# Direct IPv6 reachability -- no gateway translation at all.
# Thread network topology and roles:
$ sudo ot-ctl router table
# | ID | RLOC16 | Next Hop | Path Cost | LQI In | LQI Out | Age | Extended MAC |
# | 12 | 0x3000 | 63 | 0 | 3 | 3 | 12 | 8aa57b3c1e4f2d09 |
# | 22 | 0x5800 | 12 | 1 | 3 | 3 | 8 | 2f9e1a7c5b3d8046 |
# ^^^ path cost = hop count
$ sudo ot-ctl child table
# | ID | RLOC16 | Timeout | Age | LQ In | C_VN | R | D | N | Extended MAC |
# | 1 | 0x3001 | 240 | 23 | 3 | 17 | 0 | 0 | 0 | 6c2a9f4b1d8e3057 |
# ^ R=0: a SLEEPY end device
# Signal quality per link (LQI 0-3, higher is better):
$ sudo ot-ctl neighbor table
# | Role | RLOC16 | Age | Avg RSSI | Last RSSI |
# | C | 0x3001 | 23 | -62 | -61 |
# | R | 0x5800 | 8 | -71 | -70 |
# Role C = child (end device), R = router.
# Zigbee network via zigbee2mqtt:
$ mosquitto_sub -t 'zigbee2mqtt/bridge/devices' -C 1 | \
jq -r '.[] | "\(.friendly_name) \(.type) \(.power_source)"'
# Coordinator Coordinator Mains (single phase)
# living_room_bulb Router Mains (single phase)
# hallway_sensor EndDevice Battery
# Note: only MAINS devices are Routers. This is enforced by the role model.
# Zigbee channel and its Wi-Fi overlap:
$ mosquitto_sub -t 'zigbee2mqtt/bridge/info' -C 1 | jq '.network.channel'
# 20
# Channel 20 (2450 MHz) sits between Wi-Fi 6 and 11: a good choice.
# Link quality across the Zigbee mesh:
$ mosquitto_pub -t 'zigbee2mqtt/bridge/request/networkmap' -m '{"type":"raw"}'
$ mosquitto_sub -t 'zigbee2mqtt/bridge/response/networkmap' -C 1 | \
jq -r '.data.links[] | "\(.sourceIeeeAddr) -> \(.targetIeeeAddr) lqi=\(.lqi)"'
# 0x00124b00... -> 0x00158d00... lqi=168
# 0x00124b00... -> 0x0017880a... lqi=54 <- weak link: add a router
# LQI below ~100 indicates a marginal link that will drop under load.
# Count hops to each device (deep meshes are fragile):
$ mosquitto_sub -t 'zigbee2mqtt/bridge/response/networkmap' -C 1 | \
jq -r '.data.nodes[] | "\(.friendlyName) depth=\(.depth)"' | sort -t= -k2 -n
# Coordinator depth=0
# living_room_bulb depth=1
# hallway_sensor depth=2
# garage_sensor depth=5 <- 5 hops: ~90% reliability, ~75 ms latency
# Verify Matter over Thread:
$ chip-tool discover commissionables
# Discovered node: hostname=B8D5...... vendor=0x1234 product=0x5678
# transport=Thread
Key takeaways
- Both build on IEEE 802.15.4 (250 kb/s, 127-byte frames), whose small frame size is what forces the header compression of the next topic.
- The three roles are coordinator/border router, router (mains-powered, always listening), and end device (battery, sleeps, cannot relay).
- A node cannot both sleep and relay, so battery devices are always leaves — adding sensors never extends a mesh; only mains-powered routers do.
- Reliability falls geometrically with hop count, so practical meshes target 3–4 hops rather than the theoretical maximum.
- Zigbee is proprietary at the network layer with a defined application layer, requiring a translating gateway; Thread is IPv6-native, so its border router only forwards packets.
- Thread self-heals with leader election; Zigbee depends on a single coordinator.
- Thread deliberately omits an application layer, which Matter provides; Thread + Matter is the direction for new work, while Zigbee remains correct for existing and industrial deployments.
- Choosing 802.15.4 channel 15, 20, 25, or 26 avoids the dominant Wi-Fi channels and is the single most effective reliability improvement.
🧪 Practice
- Explain why a battery-powered sensor cannot act as a mesh router, and what that implies for extending coverage.
- Compute end-to-end reliability and latency for a 6-hop path at 98% per-hop success and 15 ms per hop.
- List the work a Zigbee gateway must do that a Thread border router does not, and state the consequence for interoperability.
- Interview-style: A smart-home mesh works reliably until the owner installs a new Wi-Fi router, after which sensors drop out. (Hint: both systems share a band — ask which channel each is using and whether they overlap.)
LoRaWAN and LPWAN
Theory
Zigbee and Thread cover a building. Some applications need to cover a city: water meters in basements, agricultural sensors across a farm, asset trackers on shipping containers. These share a profile that neither Wi-Fi nor cellular serves well — very long range, very low power, very little data, and very low cost.
LPWAN (Low-Power Wide-Area Network) is the category, and its defining trade is stark: give up throughput almost entirely in exchange for range and battery life. A LoRaWAN device sends a few dozen bytes a few times an hour, at rates measured in hundreds of bits per second, and lasts a decade on two AA cells.
The physics that makes 15 km links possible on 25 mW is processing gain. LoRa uses chirp spread spectrum: each symbol is a frequency sweep spread across the channel bandwidth. Spreading a signal over more time and bandwidth lets the receiver integrate it out of the noise — LoRa demodulates signals below the noise floor, at SNRs as low as −20 dB, where conventional modulation needs positive SNR.
The controlling parameter is the spreading factor (SF7–SF12). Each step up doubles the time on air and adds roughly 2.5 dB of link budget. This is a direct, explicit trade between range and data rate:
| SF | Data rate | Time on air (12 B) | Range | Sensitivity |
|---|---|---|---|---|
| SF7 | 5470 b/s | ~40 ms | ~2 km | −123 dBm |
| SF9 | 1760 b/s | ~145 ms | ~6 km | −129 dBm |
| SF12 | 250 b/s | ~1200 ms | ~15 km | −137 dBm |
The time on air column is the one that matters operationally, because unlicensed sub-GHz bands impose a duty cycle limit — 1% in EU868 for most channels. A device at SF12 transmitting for 1.2 seconds must then stay silent for nearly two minutes. This is the binding constraint on LoRaWAN capacity, far more than bandwidth.
LoRaWAN is the network layer above LoRa, and its architecture is deliberately simple. Devices do not associate with a gateway. They broadcast, and every gateway in range receives and forwards the packet to a central network server, which deduplicates. There is no handover, no association, no mobility management — a moving device simply works, because it was never attached to anything.
Three device classes trade downlink latency against power:
- Class A — uplink-initiated, with two brief receive windows afterwards. Lowest power; downlink must wait for the device to transmit. The default.
- Class B — scheduled receive slots synchronised by gateway beacons. Bounded downlink latency.
- Class C — continuously listening. Immediate downlink, but mains power required.
The LPWAN field has three main competitors:
| LoRaWAN | NB-IoT | Sigfox | |
|---|---|---|---|
| Spectrum | Unlicensed | Licensed (cellular) | Unlicensed |
| Deployment | Private or public | Operator only | Operator only |
| Data rate | 0.3–50 kb/s | 20–250 kb/s | 100 b/s |
| Messages/day | Duty-cycle limited | Unlimited | 140 (uplink) |
| Battery | 10+ years | 5–10 years | 10+ years |
| Cost per device | Low | Moderate | Low |
The decisive practical difference is that LoRaWAN can be deployed privately — a farm or a factory can run its own gateways with no operator relationship — whereas NB-IoT requires a subscription but provides licensed-spectrum reliability and better indoor penetration.
Examples
The LPWAN trade: throughput sacrificed for range and battery
technology range rate power use
-------------------------------------------------------------------
Wi-Fi 50 m 1 Gb/s high everything indoors
BLE 50 m 1 Mb/s low wearables
Zigbee/Thread 20 m/hop 250 kb/s low building mesh
LoRaWAN 15 km 250 b/s VERY low city-wide sensors
NB-IoT 10 km 100 kb/s very low licensed IoT
LoRaWAN is 4,000,000x slower than Wi-Fi and reaches 300x further
on a fraction of the power. That is the entire value proposition.
SPREADING FACTOR: an explicit range/rate dial
SF7 [##] 40 ms on air, 5470 b/s, ~2 km
SF8 [####] 72 ms, 3125 b/s, ~3 km
SF9 [########] 145 ms, 1760 b/s, ~6 km
SF10 [################] 290 ms, 980 b/s, ~8 km
SF11 [################################] 580 ms, 440 b/s, ~11 km
SF12 [################################################] 1200 ms, 250 b/s, ~15 km
each step: 2x time on air, +2.5 dB link budget
-> and time on air is what the DUTY CYCLE limit constrains
THE DUTY CYCLE CONSTRAINT (EU868: 1%)
SF12: 1.2 s on air -> must then be SILENT for 118.8 s
-> maximum 29 messages per hour, ever
SF7: 0.04 s on air -> silent for 3.96 s
-> maximum 900 messages per hour
This, not bandwidth, is what limits LoRaWAN capacity.
A device stuck at SF12 is 30x less useful than one at SF7.
LORAWAN ARCHITECTURE: no association, no handover
[device] - - broadcast - -> [gateway A] -\
\ \
\- - - - - - - - - -> [gateway B] -----> [network server]
\ / | deduplicates
\- - - - - - - - -> [gateway C] -/ | decrypts
v
[application server]
The device is attached to NOTHING. Every gateway that hears it forwards.
-> mobility is free: a moving device needs no handover
-> coverage improves by adding gateways, with no device reconfiguration
DEVICE CLASSES: downlink latency vs power
CLASS A (default, lowest power)
uplink ->| |RX1| |RX2| (sleep)
1s ^ 2s ^
downlink possible ONLY in those two brief windows
CLASS B (beacon-synchronised)
|ping| |ping| |ping| |ping|
bounded downlink latency, moderate power
CLASS C (always listening)
[==========================================]
immediate downlink, MAINS POWER REQUIRED
import math
# --- Spreading factor: the core trade-off ---
SF_TABLE = [
# SF, bitrate b/s, sensitivity dBm, approx range km
(7, 5470, -123, 2),
(8, 3125, -126, 3),
(9, 1760, -129, 6),
(10, 980, -132, 8),
(11, 440, -134.5, 11),
(12, 250, -137, 15),
]
def time_on_air_ms(payload_bytes, sf, bw_khz=125, cr=1, preamble=8):
"""LoRa time-on-air, per the Semtech formula."""
t_sym = (2 ** sf) / (bw_khz * 1000) * 1000 # ms
t_preamble = (preamble + 4.25) * t_sym
de = 1 if sf >= 11 else 0 # low-rate optimisation
num = 8 * payload_bytes - 4 * sf + 28 + 16
den = 4 * (sf - 2 * de)
n_payload = max(math.ceil(num / den) * (cr + 4), 0) + 8
return t_preamble + n_payload * t_sym
print(f"{'SF':>4}{'bitrate':>10}{'ToA (12B)':>12}{'sensitivity':>13}"
f"{'range':>8}{'msgs/hour (1% duty)':>21}")
for sf, rate, sens, rng in SF_TABLE:
toa = time_on_air_ms(12, sf)
msgs = int(3600 * 0.01 / (toa / 1000))
print(f"{sf:>4}{rate:>8} b/s{toa:>10.0f} ms{sens:>11} dBm{rng:>6} km{msgs:>21}")
# SF bitrate ToA (12B) sensitivity range msgs/hour (1% duty)
# 7 5470 b/s 41 ms -123 dBm 2 km 877
# 8 3125 b/s 72 ms -126 dBm 3 km 497
# 9 1760 b/s 144 ms -129 dBm 6 km 249
# 10 980 b/s 289 ms -132 dBm 8 km 124
# 11 440 b/s 642 ms -134.5 dBm 11 km 56
# 12 250 b/s 1319 ms -137 dBm 15 km 27
# A device at SF12 can send 27 messages per hour. At SF7, 877.
# Adaptive Data Rate exists to push devices DOWN the SF ladder.
# --- Link budget: how 15 km on 25 mW is possible ---
def link_budget_km(tx_dbm, tx_gain, rx_gain, sensitivity_dbm, freq_mhz=868,
exponent=2.7):
budget = tx_dbm + tx_gain + rx_gain - sensitivity_dbm
fspl_1m = 20*math.log10(0.001) + 20*math.log10(freq_mhz) + 32.44
return 10 ** ((budget - fspl_1m) / (10 * exponent)) / 1000
print(f"\n14 dBm device, 2 dBi antenna, gateway 6 dBi, 868 MHz, n=2.7:")
print(f"{'SF':>4}{'sensitivity':>13}{'link budget':>14}{'range':>10}")
for sf, _, sens, _ in SF_TABLE:
budget = 14 + 2 + 6 - sens
print(f"{sf:>4}{sens:>11} dBm{budget:>11.0f} dB"
f"{link_budget_km(14, 2, 6, sens):>8.1f} km")
# SF sensitivity link budget range
# 7 -123 dBm 145 dB 4.9 km
# 8 -126 dBm 148 dB 6.5 km
# 9 -129 dBm 151 dB 8.6 km
# 10 -132 dBm 154 dB 11.4 km
# 11 -134.5 dBm 157 dB 14.4 km
# 12 -137 dBm 159 dB 17.4 km
# A 159 dB link budget is extraordinary -- Wi-Fi manages about 100 dB.
# The difference is PROCESSING GAIN from chirp spread spectrum.
# Demodulating below the noise floor:
def noise_floor_dbm(bw_hz, nf_db=6):
return -174 + 10 * math.log10(bw_hz) + nf_db
nf = noise_floor_dbm(125_000)
print(f"\nnoise floor (125 kHz, 6 dB NF): {nf:.1f} dBm")
print(f"{'SF':>4}{'sensitivity':>13}{'SNR at sensitivity':>21}")
for sf, _, sens, _ in SF_TABLE:
print(f"{sf:>4}{sens:>11} dBm{sens - nf:>19.1f} dB")
# noise floor (125 kHz, 6 dB NF): -117.0 dBm
# SF sensitivity SNR at sensitivity
# 7 -123 dBm -6.0 dB
# 8 -126 dBm -9.0 dB
# 9 -129 dBm -12.0 dB
# 10 -132 dBm -15.0 dB
# 11 -134.5 dBm -17.5 dB
# 12 -137 dBm -20.0 dB
# NEGATIVE SNR: the signal is 20 dB BELOW the noise and still decodable.
# Compare 10.1, where Wi-Fi needed +2 dB minimum for its slowest rate.
# --- Duty cycle: the real capacity limit ---
def duty_cycle_check(toa_ms, messages_per_hour, limit=0.01):
airtime_per_hour = toa_ms * messages_per_hour / 1000
used = airtime_per_hour / 3600
return used, used <= limit, int(3600 * limit / (toa_ms / 1000))
print(f"\napplication wants 1 message every 10 minutes (6/hour):")
print(f"{'SF':>4}{'ToA':>9}{'duty used':>12}{'legal?':>9}{'max msgs/hr':>14}")
for sf, _, _, _ in SF_TABLE:
toa = time_on_air_ms(12, sf)
used, ok, maxm = duty_cycle_check(toa, 6)
print(f"{sf:>4}{toa:>7.0f}ms{used:>11.3%}{'yes' if ok else 'NO':>9}{maxm:>14}")
# SF ToA duty used legal? max msgs/hr
# 7 41ms 0.007% yes 877
# 8 72ms 0.012% yes 497
# 9 144ms 0.024% yes 249
# 10 289ms 0.048% yes 124
# 11 642ms 0.107% yes 56
# 12 1319ms 0.220% yes 27
# 6 messages/hour is fine at any SF. 60/hour would be ILLEGAL at SF12.
# --- Battery life ---
def battery_years(capacity_mah, msgs_per_day, toa_ms, tx_ma=45, sleep_ua=2):
tx_hours_per_day = msgs_per_day * toa_ms / 1000 / 3600
daily_mah = tx_ma * tx_hours_per_day + sleep_ua / 1000 * 24
return capacity_mah / daily_mah / 365
print(f"\n2x AA (2600 mAh), 24 messages/day:")
print(f"{'SF':>4}{'ToA':>9}{'battery life':>16}")
for sf, _, _, _ in SF_TABLE:
toa = time_on_air_ms(12, sf)
print(f"{sf:>4}{toa:>7.0f}ms{battery_years(2600, 24, toa):>13.1f} yr")
# SF ToA battery life
# 7 41ms 14.5 yr
# 8 72ms 14.3 yr
# 9 144ms 13.9 yr
# 10 289ms 13.2 yr
# 11 642ms 11.7 yr
# 12 1319ms 9.5 yr
# Even at SF12 the battery outlasts the product. SLEEP current, not
# transmission, dominates the budget at these message rates.
# --- Device classes ---
CLASSES = [
("A", "uplink-initiated, 2 RX windows", 0.02, "up to the next uplink",
"battery sensors (default)"),
("B", "beacon-scheduled ping slots", 0.5, "bounded (~128 s worst case)",
"actuators needing occasional downlink"),
("C", "continuously receiving", 45.0, "immediate",
"mains-powered actuators"),
]
print(f"\n{'class':<7}{'behaviour':<34}{'avg mA':>9}{'downlink latency':>28}")
for c, beh, ma, lat, use in CLASSES:
print(f"{c:<7}{beh:<34}{ma:>9.2f}{lat:>28}")
print()
for c, _, ma, _, use in CLASSES:
life = 2600 / ma / 24 / 365 if ma > 0 else 0
life_s = f"{life:.1f} years" if life > 1 else f"{life*365:.0f} days"
print(f" class {c}: {life_s:<12} on 2x AA ({use})")
# class A: 14.8 years on 2x AA (battery sensors (default))
# class B: 0.6 years on 2x AA (actuators needing occasional downlink)
# class C: 2 days on 2x AA (mains-powered actuators)
# Class C is simply not a battery option. The class choice IS the
# power budget decision.
# --- LPWAN comparison ---
LPWAN = [
("LoRaWAN", "unlicensed", "private or public", "0.3-50 kb/s",
"duty-cycle limited", "10+ yr"),
("NB-IoT", "LICENSED", "operator only", "20-250 kb/s",
"unlimited", "5-10 yr"),
("Sigfox", "unlicensed", "operator only", "100 b/s",
"140 msgs/day", "10+ yr"),
("LTE-M", "LICENSED", "operator only", "1 Mb/s",
"unlimited", "5-10 yr"),
]
print(f"\n{'tech':<10}{'spectrum':<13}{'deployment':<20}{'rate':<14}"
f"{'message limit':<22}{'battery'}")
for row in LPWAN:
print(f"{row[0]:<10}{row[1]:<13}{row[2]:<20}{row[3]:<14}{row[4]:<22}{row[5]}")
# The decisive column is DEPLOYMENT: only LoRaWAN can be run privately,
# which is why farms, factories, and campuses choose it.
# LoRaWAN gateway packet forwarder log -- note SF and SNR per packet:
$ sudo journalctl -u lora-packet-forwarder -f
# INFO: Received pkt from mote: 26011F8A (fcnt=1204)
# freq=868.1 MHz, datarate=SF9BW125, rssi=-112.0, snr=-8.5
# ^^^^^^^^^ ^^^^^^^^^^ ^^^^^^^^
# SF9 weak signal NEGATIVE SNR, still decoded
# Adaptive Data Rate pushing a device down the SF ladder:
$ mosquitto_sub -t 'v3/+/devices/+/up' -C 5 | \
jq -r '.uplink_message.settings.data_rate.lora | "SF\(.spreading_factor)BW\(.bandwidth/1000)"'
# SF12BW125
# SF10BW125
# SF9BW125
# SF7BW125
# SF7BW125
# The network server told the device it could use a faster rate,
# cutting airtime from 1300 ms to 41 ms and freeing duty-cycle budget.
# Duty cycle accounting on a gateway:
$ curl -s http://gateway.local/api/v1/gateway/stats | \
jq '{tx_packets, tx_airtime_ms, duty_cycle_pct: (.tx_airtime_ms/36000)}'
# {
# "tx_packets": 412,
# "tx_airtime_ms": 24120,
# "duty_cycle_pct": 0.67
# }
# 0.67% of the 1% budget used. Above 1% the gateway must stop transmitting.
# Compute time on air before deploying:
$ python3 -c "
import math
def toa(payload, sf, bw=125000, cr=1, pre=8):
ts = (2**sf)/bw*1000
de = 1 if sf>=11 else 0
n = max(math.ceil((8*payload-4*sf+28+16)/(4*(sf-2*de)))*(cr+4),0)+8
return (pre+4.25)*ts + n*ts
for sf in range(7,13):
print(f'SF{sf}: {toa(24,sf):.0f} ms for a 24-byte payload')"
# SF7: 62 ms for a 24-byte payload
# SF8: 113 ms for a 24-byte payload
# ...
# SF12: 1810 ms for a 24-byte payload
# Multiple gateways receiving the same uplink (no association needed):
$ mosquitto_sub -t 'v3/+/devices/+/up' -C 1 | jq -r '.uplink_message.rx_metadata[] |
"\(.gateway_ids.gateway_id) rssi=\(.rssi) snr=\(.snr)"'
# gw-rooftop-north rssi=-104 snr=2.5
# gw-rooftop-south rssi=-118 snr=-11.2
# gw-warehouse rssi=-121 snr=-14.8
# THREE gateways heard one transmission. The network server deduplicates
# and uses the best copy. This is why LoRaWAN mobility is free.
# Device class and its power implication:
$ mosquitto_sub -t 'v3/+/devices/+/up' -C 1 | jq '.uplink_message.f_port,
.end_device_ids.device_id, .uplink_message.settings.timestamp'
$ curl -s https://eu1.cloud.thethings.network/api/v3/ns/applications/myapp/devices/sensor01 \
-H "Authorization: Bearer $TTN_KEY" | jq '.mac_settings.class_b_timeout, .supports_class_c'
# null
# false
# Class A: the lowest-power default.
# NB-IoT for comparison -- it appears as a cellular modem:
$ mmcli -m 0 | grep -E 'access tech|signal'
# | access tech: lte (NB-IoT)
$ mmcli -m 0 --signal-get | grep -i rsrp
# | rsrp: -118.00 dBm
# NB-IoT tolerates -140 dBm, ~20 dB better than standard LTE -- which is
# how it reaches meters in basements.
# Check the regulatory duty cycle for your region:
$ grep -A3 'EU868' /etc/lorawan/regional-params.conf
# EU868:
# duty_cycle: 0.01 # 1%
# max_eirp: 16 # dBm
# channels: 868.1, 868.3, 868.5, 867.1, 867.3, 867.5, 867.7, 867.9
# US915 has no duty cycle limit but mandates frequency hopping instead.
Key takeaways
- LPWAN trades throughput almost entirely for range and battery life — LoRaWAN is ~4,000,000× slower than Wi-Fi and reaches ~300× further.
- Chirp spread spectrum gives processing gain, letting LoRa demodulate signals 20 dB below the noise floor, where Wi-Fi needs positive SNR.
- The spreading factor (SF7–SF12) is an explicit range/rate dial: each step doubles time on air and adds ~2.5 dB of link budget.
- Duty cycle limits (1% in EU868), not bandwidth, bound LoRaWAN capacity — a device at SF12 can send only ~27 messages per hour.
- Adaptive Data Rate pushes devices to the lowest usable SF, which is the main lever for network capacity.
- Devices are not associated with any gateway: they broadcast, every gateway in range forwards, and the network server deduplicates — so mobility is free and coverage improves by adding gateways.
- Class A (uplink-initiated) is the battery default; Class C (always listening) requires mains power.
- LoRaWAN's decisive advantage is private deployment; NB-IoT requires an operator but gives licensed-spectrum reliability and better indoor penetration.
🧪 Practice
- Compute the time on air and maximum legal messages per hour at SF10 and SF12 for a 12-byte payload under a 1% duty cycle.
- Explain how LoRa decodes a signal at −20 dB SNR and why conventional modulation cannot.
- A device must receive commands within 5 seconds. State which LoRaWAN class is required and its battery consequence.
- Interview-style: A LoRaWAN deployment works in testing and fails when scaled to 500 devices. (Hint: capacity here is measured in airtime, not bits — work out what fraction of the hour 500 devices at SF12 would collectively occupy.)
6LoWPAN and RPL
Theory
Thread's IPv6-native design raises an obvious objection: an IPv6 header is 40 bytes, a UDP header adds 8, and an 802.15.4 frame carries at most 127 bytes — of which link-layer headers and security take 25 or more. Naively, 48 bytes of the remaining ~102 would be protocol overhead before a single byte of application data. IPv6 appears impossible on these links.
6LoWPAN (IPv6 over Low-Power Wireless Personal Area Networks), RFC 4944 and 6282, is the adaptation layer that makes it work. It contributes three mechanisms:
Header compression is the important one. Most header fields are either predictable or derivable from the link layer, so they need not be transmitted:
- The version is always 6.
- Traffic class and flow label are usually zero.
- The payload length is derivable from the link-layer frame length.
- The hop limit is commonly one of a few standard values.
- Addresses are the biggest win: if an IPv6 address was formed from the link-layer address (the standard IID derivation), it can be elided entirely — the receiver reconstructs it from the MAC header it already has.
The result is that a 48-byte IPv6+UDP header pair compresses to as few as 6 bytes in the best case, leaving the frame mostly available for payload.
Fragmentation handles the mismatch between IPv6's required 1280-byte MTU and 802.15.4's 127-byte frame. 6LoWPAN fragments and reassembles below IPv6, so the IP layer keeps its guarantee. Fragmentation is expensive on these links, though — losing one fragment discards the whole datagram — so well-designed applications keep messages small enough to avoid it.
Mesh addressing and neighbour discovery are adapted for links where multicast is costly and nodes sleep.
RPL (Routing Protocol for Low-Power and Lossy Networks), RFC 6550, is the routing protocol above it. It is worth understanding why the protocols from Chapter 6 do not fit: OSPF floods link-state information (too much traffic and memory for a node with 32 KB of RAM), and distance-vector protocols converge slowly and assume fairly stable links, whereas these links fluctuate constantly.
RPL builds a DODAG (Destination-Oriented Directed Acyclic Graph) — essentially a tree rooted at the border router. Each node selects a parent based on an objective function, typically minimising ETX (Expected Transmission Count), which counts expected retransmissions rather than hops. A 2-hop path over good links beats a 1-hop path over a marginal one, which is exactly right for lossy radio.
RPL supports three traffic patterns, and the asymmetry is deliberate: upward (node to root) traffic is cheap, because every node simply forwards to its parent; downward (root to node) requires the root to store routes; node to node normally travels up to a common ancestor and back down.
Its trickle timer algorithm is an elegant piece of design: control messages are sent frequently when the topology is changing and exponentially less often when it is stable, so a settled network becomes nearly silent — which is precisely what battery devices need.
Examples
The problem: IPv6 does not fit in an 802.15.4 frame
802.15.4 frame: 127 bytes maximum
+----------------------------------------------------------+
| MAC hdr 23 | IPv6 hdr 40 | UDP hdr 8 | payload 56 | FCS 2 |
+----------------------------------------------------------+
\_______________________/
48 bytes of header for 56 bytes of data
-> 46% overhead before you send anything
WITH 6LoWPAN COMPRESSION (best case):
+----------------------------------------------------------+
| MAC hdr 23 | IPHC 2 | NHC 4 | payload 96 | FCS 2 |
+----------------------------------------------------------+
\_____________/
6 bytes -> 94 bytes of payload available
-> 8x more room for data
WHAT CAN BE ELIDED, AND WHY
IPv6 field size compressed why
------------------------------------------------------------------
version 4b 0 always 6
traffic class 8b 0 usually zero
flow label 20b 0 usually zero
payload length 16b 0 DERIVABLE from the frame length
next header 8b 0-8b common values are encoded
hop limit 8b 0-8b 1, 64, 255 encoded as 2 bits
source address 128b 0-128b ELIDED if derived from the MAC
dest address 128b 0-128b ELIDED if derived from the MAC
------------------------------------------------------------------
total 320b 16-320b
The address elision is the big win: the receiver already has the
link-layer address in the MAC header, and the IID was derived from it.
RPL DODAG: a tree rooted at the border router
[border router] rank 1
/ \
[R1] rank 2 [R2] rank 2
/ \ \
[R3] r3 [R4] r3 [R5] r3
/ \
[E1] r4 [E2] r4
UPWARD (node -> root): trivial, forward to your parent
DOWNWARD (root -> node): the root must STORE routes
NODE -> NODE: up to a common ancestor, then down
RANK increases with distance from the root and prevents loops:
a node never selects a parent with a higher rank than itself.
ETX BEATS HOP COUNT ON LOSSY LINKS
path A: 1 hop, link delivers 50% of the time -> ETX = 2.0
path B: 2 hops, links deliver 95% each -> ETX = 1.05 + 1.05 = 2.1
path C: 2 hops, links deliver 99% each -> ETX = 1.01 + 1.01 = 2.02
hop count says A wins. ETX says C wins, and C is genuinely better:
fewer retransmissions, less energy, lower latency.
import math
# --- Header compression: counting the bytes ---
def header_size(compression):
IPV6, UDP, MAC, FCS = 40, 8, 23, 2
FRAME = 127
if compression == "none":
hdr = IPV6 + UDP
elif compression == "IPHC best case":
hdr = 2 + 4 # IPHC 2 bytes + NHC-compressed UDP 4
elif compression == "IPHC typical":
hdr = 3 + 5
else: # partial: one address carried in full
hdr = 2 + 16 + 4
return hdr, FRAME - MAC - FCS - hdr
print(f"{'compression':<22}{'header':>9}{'payload available':>20}{'efficiency':>13}")
for mode in ("none", "partial (1 addr inline)", "IPHC typical", "IPHC best case"):
hdr, payload = header_size(mode)
print(f"{mode:<22}{hdr:>8}B{payload:>18}B{payload/102:>12.0%}")
# compression header payload available efficiency
# none 48B 54B 53%
# partial (1 addr inline) 22B 80B 78%
# IPHC typical 8B 94B 92%
# IPHC best case 6B 96B 94%
# Compression nearly doubles usable payload. Without it, IPv6 on
# 802.15.4 would waste almost half of every frame.
# Which fields get elided, and the saving from each:
FIELDS = [
("version", 4, 0, "always 6"),
("traffic class", 8, 0, "usually zero"),
("flow label", 20, 0, "usually zero"),
("payload length",16, 0, "derivable from the frame length"),
("next header", 8, 0, "NHC encodes common values"),
("hop limit", 8, 2, "1/64/255 encoded in 2 bits"),
("source addr", 128, 0, "ELIDED: derived from the MAC address"),
("dest addr", 128, 0, "ELIDED: derived from the MAC address"),
]
total_bits = sum(f[1] for f in FIELDS)
kept_bits = sum(f[2] for f in FIELDS)
print(f"\n{'field':<18}{'bits':>7}{'kept':>7} reason")
for name, bits, kept, why in FIELDS:
print(f"{name:<18}{bits:>7}{kept:>7} {why}")
print(f"{'TOTAL':<18}{total_bits:>7}{kept_bits:>7} "
f"({total_bits//8} B -> {math.ceil(kept_bits/8)} B)")
# field bits kept reason
# version 4 0 always 6
# ...
# TOTAL 320 2 (40 B -> 1 B)
# --- Fragmentation: why it should be avoided ---
def fragments_needed(payload_bytes, first_frag=96, subsequent=88):
if payload_bytes <= first_frag:
return 1
return 1 + math.ceil((payload_bytes - first_frag) / subsequent)
def datagram_success(frags, per_frag_success=0.95):
return per_frag_success ** frags
print(f"\n{'payload':>9}{'fragments':>11}{'success (95%/frag)':>21}{'effective loss':>17}")
for size in (50, 96, 200, 500, 1280):
f = fragments_needed(size)
s = datagram_success(f)
print(f"{size:>7} B{f:>11}{s:>20.1%}{1-s:>16.1%}")
# payload fragments success (95%/frag) effective loss
# 50 B 1 95.0% 5.0%
# 96 B 1 95.0% 5.0%
# 200 B 3 85.7% 14.3%
# 500 B 6 73.5% 26.5%
# 1280 B 14 48.8% 51.2%
# A 1280-byte datagram (IPv6's minimum MTU) fails HALF the time on a
# link with 5% frame loss. Keep application messages under ~96 bytes.
# --- ETX: why hop count is the wrong metric ---
def etx(link_success_rates):
"""Expected transmissions = sum over hops of 1/delivery_ratio."""
return sum(1 / r for r in link_success_rates)
PATHS = {
"1 hop, 50% link": [0.50],
"1 hop, 80% link": [0.80],
"2 hops, 95% links": [0.95, 0.95],
"2 hops, 99% links": [0.99, 0.99],
"3 hops, 98% links": [0.98, 0.98, 0.98],
}
print(f"\n{'path':<24}{'hops':>6}{'ETX':>8}{'hop-count choice':>19}{'ETX choice':>13}")
best_hops = min(PATHS.items(), key=lambda kv: len(kv[1]))[0]
best_etx = min(PATHS.items(), key=lambda kv: etx(kv[1]))[0]
for name, links in PATHS.items():
print(f"{name:<24}{len(links):>6}{etx(links):>8.2f}"
f"{('<- chosen' if name == best_hops else ''):>19}"
f"{('<- chosen' if name == best_etx else ''):>13}")
# path hops ETX hop-count choice ETX choice
# 1 hop, 50% link 1 2.00 <- chosen
# 1 hop, 80% link 1 1.25
# 2 hops, 95% links 2 2.11
# 2 hops, 99% links 2 2.02
# 3 hops, 98% links 3 3.06
# Hop count picks the first 1-hop path it finds. ETX picks the 80% link,
# which needs 1.25 transmissions instead of 2 -- 37% less energy.
# --- RPL rank and loop avoidance ---
class RPLNode:
def __init__(self, node_id, rank=None):
self.id, self.rank, self.parent = node_id, rank, None
def select_parent(self, candidates, min_hop_rank_increase=256):
"""A node may only choose a parent with a LOWER rank -> no loops."""
viable = [(c, e) for c, e in candidates if c.rank is not None]
if not viable:
return None, "no viable parent"
best, best_etx = min(viable, key=lambda ce: ce[0].rank + ce[1] * min_hop_rank_increase)
self.parent = best
self.rank = best.rank + int(best_etx * min_hop_rank_increase)
return best, f"rank {self.rank}"
root = RPLNode("border-router", rank=256)
r1, r2 = RPLNode("R1"), RPLNode("R2")
r1.select_parent([(root, 1.05)])
r2.select_parent([(root, 1.30)])
e1 = RPLNode("E1")
parent, msg = e1.select_parent([(r1, 1.10), (r2, 1.02)])
print(f"\nRPL parent selection:")
for n in (root, r1, r2, e1):
p = n.parent.id if n.parent else "-"
print(f" {n.id:<15} rank={n.rank:<6} parent={p}")
print(f"\nE1 chose {parent.id}: lower TOTAL rank, not fewer hops")
# border-router rank=256 parent=-
# R1 rank=524 parent=border-router
# R2 rank=588 parent=border-router
# E1 rank=785 parent=R1
#
# E1 chose R1: lower TOTAL rank, not fewer hops
# Rank strictly increases away from the root, which is what makes
# loops structurally impossible.
# --- Trickle timer: a stable network goes quiet ---
def trickle_intervals(imin_ms=128, imax_doublings=16, events=None):
events = events or {}
interval, t, schedule = imin_ms, 0, []
for step in range(12):
schedule.append((t, interval))
t += interval
if step in events: # topology change: RESET
interval = imin_ms
else:
interval = min(interval * 2, imin_ms * (2 ** imax_doublings))
return schedule
print(f"\ntrickle timer (topology change at step 5):")
print(f"{'t (s)':>9}{'interval':>12}")
for t, i in trickle_intervals(events={5: "change"}):
print(f"{t/1000:>9.1f}{i/1000:>10.1f}s")
# t (s) interval
# 0.0 0.1s
# 0.1 0.3s
# 0.4 0.5s
# 0.9 1.0s
# 1.9 2.0s
# 3.9 4.1s
# 8.0 0.1s <- RESET: the network is changing again
# 8.1 0.3s
# ...
# Stable network -> intervals grow exponentially -> almost no control
# traffic -> batteries last. A change resets it for fast reconvergence.
# 6LoWPAN interface on Linux:
$ ip link show lowpan0
# 5: lowpan0@wpan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1280
# ^^^^^^^^ IPv6's minimum,
# achieved by fragmentation
$ ip -6 addr show lowpan0
# inet6 fd00::e4d5:6f3a:2b1c:8907/64 scope global
# inet6 fe80::e4d5:6f3a:2b1c:8907/64 scope link
# A real IPv6 address on a 127-byte-frame radio.
# The underlying 802.15.4 interface:
$ iwpan dev wpan0 info
# wpan0
# type: node
# channel: 15
# page: 0
# short_addr: 0x1234
# extended_addr: 0xe6d56f3a2b1c8907
# Note the extended address: the IPv6 IID is derived from it, which is
# exactly what makes address elision possible.
# Observe compression on the air:
$ sudo tshark -i wpan0 -Y '6lowpan' -T fields \
-e frame.len -e 6lowpan.pattern -e ipv6.src -c 3 2>/dev/null
# 41 3 fd00::e4d5:6f3a:2b1c:8907
# 38 3 fd00::e4d5:6f3a:2b1c:8907
# 41-byte frames carrying full IPv6 semantics. Uncompressed this would
# need 48 bytes of header alone, exceeding what the frame can hold.
# Verify IPv6 works end to end:
$ ping6 -c 3 -s 40 fd00::1a2b:3c4d:5e6f:7890
# 3 packets transmitted, 3 received, 0% packet loss
# rtt min/avg/max/mdev = 41.2/58.7/79.4/15.8 ms
# Fragmentation kicks in above the compressed payload size:
$ ping6 -c 3 -s 200 fd00::1a2b:3c4d:5e6f:7890
# 3 packets transmitted, 2 received, 33% packet loss
# ^^^ fragmentation amplifies loss
$ sudo tshark -i wpan0 -Y '6lowpan.frag' -c 4 2>/dev/null
# 6LoWPAN Fragment, datagram size 208, tag 0x1a2b, offset 0
# 6LoWPAN Fragment, datagram size 208, tag 0x1a2b, offset 96
# Lose either fragment and the whole datagram is discarded.
# RPL DODAG state (OpenThread / Contiki-NG):
$ sudo ot-ctl router table
# | ID | RLOC16 | Next Hop | Path Cost | LQI In | LQI Out |
# | 12 | 0x3000 | 63 | 0 | 3 | 3 | <- the root
# | 22 | 0x5800 | 12 | 1 | 3 | 3 |
# ^^^^^^^^^ path cost, RPL's rank analogue
# RPL parent and rank on a Contiki-NG node:
$ make login TARGET=zoul
# [INFO: RPL ] rpl_set_preferred_parent fe80::212:4b00:1a2b:3c4d
# [INFO: RPL ] rank 768 -> 512
# ^^^ rank DECREASED: it found a better parent
# ETX per neighbour -- the metric driving parent selection:
$ sudo ot-ctl neighbor table
# | Role | RLOC16 | Age | Avg RSSI | Last RSSI | LQI |
# | R | 0x3000 | 5 | -58 | -57 | 3 |
# | R | 0x5800 | 12 | -79 | -81 | 1 |
# LQI 3 vs 1: the first neighbour needs far fewer retransmissions,
# so it wins even if both are one hop away.
# Watch trickle quieten as the network stabilises:
$ sudo tshark -i wpan0 -Y 'icmpv6.type == 155' -T fields -e frame.time_relative \
-a duration:120 2>/dev/null | awk 'NR>1{print $1-prev} {prev=$1}'
# 0.128
# 0.256
# 0.512
# 1.024
# 2.048
# 4.096
# 8.192
# DIO intervals doubling: the network is stable and going quiet.
Key takeaways
- IPv6's 40-byte header plus 8-byte UDP header will not fit usefully in an 802.15.4 frame of 127 bytes; 6LoWPAN is the adaptation layer that resolves it.
- Header compression (IPHC/NHC) elides predictable fields and derives addresses from the link-layer address, reducing 48 bytes to as few as 6.
- Fragmentation bridges IPv6's 1280-byte MTU to 127-byte frames, but amplifies loss geometrically — a 1280-byte datagram fails ~50% of the time at 5% frame loss, so keep messages small.
- OSPF and distance-vector protocols do not fit these devices: too much memory, too much control traffic, and wrong assumptions about link stability.
- RPL builds a DODAG rooted at the border router, with rank strictly increasing away from the root, which makes loops structurally impossible.
- ETX (expected transmission count) beats hop count on lossy links: a 2-hop path over good links genuinely outperforms 1 hop over a marginal one.
- Upward traffic is cheap, downward requires stored routes, and node-to-node normally goes up to a common ancestor and back down.
- The trickle timer makes a stable network nearly silent while reconverging quickly after a change.
🧪 Practice
- Compute the payload available in a 127-byte 802.15.4 frame with no compression and with best-case IPHC, assuming a 23-byte MAC header.
- Calculate ETX for a 1-hop path with 60% delivery and a 3-hop path with 97% per link, and state which RPL selects.
- Explain why sending 400-byte application messages over 6LoWPAN is a poor design decision.
- Interview-style: Why does RPL use rank rather than hop count, and what property does rank guarantee? (Hint: consider what must be true of every parent selection for a cycle to be impossible.)
MQTT and CoAP
Theory
The protocols of Chapter 8 assume resources that constrained devices do not have. An HTTP request with headers is often larger than a sensor's entire message, TLS handshakes cost memory a microcontroller may not possess, and keeping a TCP connection open costs energy that a battery cannot spare. IoT needed application protocols designed for the same constraints as the link layers below them.
Two dominate, and they made opposite architectural choices.
MQTT is publish/subscribe over TCP. Devices connect to a broker and publish messages to hierarchical topics (building/floor2/room14/temperature); other clients subscribe to topics or wildcard patterns and receive matching messages. Publishers and subscribers never know about each other — the broker decouples them completely.
This decoupling is MQTT's central value, and it solves a real problem: sensors sleep, so a subscriber cannot poll them. With a broker, the sensor publishes when it wakes and the broker holds the message for whoever wants it.
MQTT's notable features:
- Three QoS levels: 0 (at most once, fire and forget), 1 (at least once, may duplicate), 2 (exactly once, a four-way handshake). This is the same delivery-semantics problem as 9.3's idempotency discussion, solved in the protocol.
- Retained messages: the broker keeps the last message per topic, so a new subscriber immediately learns the current state rather than waiting for the next update.
- Last Will and Testament: a message the broker publishes automatically if a client disconnects ungracefully — a built-in liveness signal, which matters when devices vanish without warning.
- A 2-byte fixed header, the smallest in common use.
CoAP is REST over UDP. It deliberately mirrors HTTP — GET, POST, PUT, DELETE, URIs, response codes — so that mapping between the two is mechanical, but runs over UDP with a 4-byte header and its own lightweight reliability.
CoAP's notable features:
- Confirmable (CON) and non-confirmable (NON) messages: reliability is per message, not per connection, so a device can choose to pay for an acknowledgement only when it matters.
- Observe: a subscription extension that turns a GET into a push notification stream, recovering pub/sub semantics without a broker.
- Block-wise transfer: fragments large payloads at the application layer, avoiding the IP fragmentation that 6LoWPAN makes so costly.
- DTLS for security, since TLS requires TCP.
| MQTT | CoAP | |
|---|---|---|
| Transport | TCP | UDP |
| Pattern | Pub/sub via a broker | Request/response (REST) |
| Header | 2 bytes | 4 bytes |
| Discovery | None (topics by convention) | /.well-known/core |
| Reliability | QoS 0/1/2 (from TCP + protocol) | CON/NON per message |
| Multicast | No | Yes |
| Security | TLS | DTLS |
| Needs infrastructure | Yes — a broker | No |
The choice is genuinely architectural rather than a matter of taste. MQTT suits many-to-many telemetry where devices sleep and a central system collects data — its broker is a feature. CoAP suits direct device interaction, constrained networks where UDP's lack of connection state matters, and cases where you want each device to be an addressable resource. MQTT-SN exists for non-TCP links, and both coexist happily in the same deployment.
Examples
The architectural difference
MQTT: everything through a broker (decoupled)
[sensor A] --publish--> +--------+ --deliver--> [dashboard]
[sensor B] --publish--> | BROKER | --deliver--> [database]
[sensor C] --publish--> +--------+ --deliver--> [alerting]
^
publishers and subscribers
NEVER know about each other
-> a sleeping sensor can still "serve" data: it published earlier
-> adding a consumer requires no change to any device
CoAP: direct request/response (like HTTP)
[client] --GET coap://sensor-a/temperature--> [sensor A]
<-------- 2.05 Content 22.4 --------
-> the device must be AWAKE when asked
-> but each device is an addressable resource, no infrastructure needed
MQTT TOPIC HIERARCHY AND WILDCARDS
building/floor2/room14/temperature
building/floor2/room14/humidity
building/floor2/room15/temperature
building/floor3/room01/temperature
subscriptions:
building/floor2/room14/temperature -> exactly one topic
building/floor2/+/temperature -> + = ONE level wildcard
(room14 and room15)
building/# -> # = MULTI-level wildcard
(everything below)
MQTT QoS: the delivery-semantics trade-off (cf. 9.3)
QoS 0 PUBLISH ------------------> at most once
fire and forget; may be lost
QoS 1 PUBLISH ------------------> at least once
<----------------- PUBACK MAY DUPLICATE
(if PUBACK is lost, the publisher resends)
QoS 2 PUBLISH ------------------> exactly once
<----------------- PUBREC 4 messages,
PUBREL -------------------> highest cost
<----------------- PUBCOMP
Same problem as 9.3's idempotency: QoS 1 needs idempotent handlers,
QoS 2 pays four round trips to avoid needing them.
CoAP OBSERVE: pub/sub without a broker
client sensor
|-- GET /temp, Observe: 0 ---->|
|<-- 2.05 Content, Observe: 12 | value 22.4
| |
|<-- 2.05 Content, Observe: 13 | value 22.6 (pushed, no request)
|<-- 2.05 Content, Observe: 14 | value 22.9 (pushed)
|
|-- GET /temp, Observe: 1 ---->| deregister
One request, a stream of responses -- structurally the same as
Server-Sent Events from 8.3, at 4 bytes per header.
import struct, math
# --- Message sizes: why HTTP does not fit ---
def message_size(protocol, payload_bytes=12, topic="bld/f2/r14/temp"):
if protocol == "HTTP/1.1":
hdr = len(
f"POST /api/v1/sensors/temp HTTP/1.1\r\n"
f"Host: api.example.com\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {payload_bytes}\r\n"
f"Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\r\n"
f"User-Agent: sensor/1.0\r\n\r\n")
return hdr + payload_bytes
if protocol == "MQTT":
return 2 + 2 + len(topic) + payload_bytes # fixed + topic len + topic
if protocol == "CoAP":
return 4 + 1 + len(topic) + 1 + payload_bytes # header + options + payload
raise ValueError(protocol)
print(f"{'protocol':<12}{'total size':>12}{'overhead':>11}{'efficiency':>13}")
for p in ("HTTP/1.1", "MQTT", "CoAP"):
size = message_size(p)
print(f"{p:<12}{size:>10} B{size-12:>9} B{12/size:>12.0%}")
# protocol total size overhead efficiency
# HTTP/1.1 231 B 219 B 5%
# MQTT 31 B 19 B 39%
# CoAP 23 B 11 B 52%
# An HTTP request is 231 bytes to carry 12 bytes of data -- and it does
# not even fit in an 802.15.4 frame after 6LoWPAN headers.
# What that costs in energy over a device's lifetime:
def annual_energy_mah(msg_bytes, msgs_per_day, rate_kbps=250, tx_ma=30):
seconds_per_msg = msg_bytes * 8 / (rate_kbps * 1000)
hours_per_year = seconds_per_msg * msgs_per_day * 365 / 3600
return tx_ma * hours_per_year
print(f"\n{'protocol':<12}{'bytes':>8}{'mAh/year (144 msgs/day)':>26}{'AA cells (10 yr)':>19}")
for p in ("HTTP/1.1", "MQTT", "CoAP"):
size = message_size(p)
mah = annual_energy_mah(size, 144)
cells = math.ceil(mah * 10 / 2500)
print(f"{p:<12}{size:>8}{mah:>24.2f}{cells:>19}")
# protocol bytes mAh/year (144 msgs/day) AA cells (10 yr)
# HTTP/1.1 231 10.80 1
# MQTT 31 1.45 1
# CoAP 23 1.08 1
# At low message rates the difference is modest; at high rates or on
# LoRaWAN's duty-cycled links, the 10x size difference is decisive.
# --- MQTT topic matching ---
def topic_matches(subscription, topic):
sub, top = subscription.split("/"), topic.split("/")
for i, s in enumerate(sub):
if s == "#":
return True # multi-level: matches the rest
if i >= len(top):
return False
if s != "+" and s != top[i]: # + matches exactly one level
return False
return len(sub) == len(top)
TOPICS = ["building/floor2/room14/temperature",
"building/floor2/room14/humidity",
"building/floor2/room15/temperature",
"building/floor3/room01/temperature"]
SUBS = ["building/floor2/room14/temperature", "building/floor2/+/temperature",
"building/+/+/temperature", "building/#", "building/floor2/#"]
print(f"\n{'subscription':<38} matches")
for sub in SUBS:
matched = [t for t in TOPICS if topic_matches(sub, t)]
print(f"{sub:<38} {len(matched)} topic(s)")
for m in matched:
print(f"{'':<38} {m}")
# subscription matches
# building/floor2/room14/temperature 1 topic(s)
# building/floor2/room14/temperature
# building/floor2/+/temperature 2 topic(s)
# building/floor2/room14/temperature
# building/floor2/room15/temperature
# building/# 4 topic(s)
# ...
# --- MQTT QoS: message count and delivery semantics ---
QOS = [
(0, 1, "at most once", "may be LOST", "telemetry where the next reading suffices"),
(1, 2, "at least once", "may DUPLICATE", "commands with idempotent handlers"),
(2, 4, "exactly once", "neither", "billing, irreversible actions"),
]
print(f"\n{'QoS':>4}{'messages':>10}{'semantics':>16}{'risk':>18} use")
for q, msgs, sem, risk, use in QOS:
print(f"{q:>4}{msgs:>10}{sem:>16}{risk:>18} {use}")
def qos_overhead(messages_per_day, qos_msgs, bytes_each=31):
return messages_per_day * qos_msgs * bytes_each
print(f"\ndaily bytes for 144 publishes:")
for q, msgs, _, _, _ in QOS:
print(f" QoS {q}: {qos_overhead(144, msgs):>6,} B ({msgs}x the messages)")
# QoS 0: 4,464 B (1x the messages)
# QoS 1: 8,928 B (2x the messages)
# QoS 2: 17,856 B (4x the messages)
# QoS 2 costs 4x the airtime and energy. Use it only where duplicates
# are genuinely unacceptable -- exactly 9.3's idempotency reasoning.
# --- Retained messages and Last Will ---
class MQTTBroker:
def __init__(self):
self.retained = {} # topic -> last message
self.wills = {} # client -> (topic, message)
self.log = []
def publish(self, topic, payload, retain=False):
if retain:
self.retained[topic] = payload
self.log.append(("publish", topic, payload))
def connect(self, client, will_topic=None, will_msg=None):
if will_topic:
self.wills[client] = (will_topic, will_msg)
def subscribe(self, topic):
"""A new subscriber immediately gets the retained message."""
matches = [(t, p) for t, p in self.retained.items() if topic_matches(topic, t)]
return matches
def client_died(self, client):
"""Ungraceful disconnect -> the broker publishes the will."""
if client in self.wills:
t, m = self.wills.pop(client)
self.publish(t, m, retain=True)
return f"published will: {t} = {m}"
return "no will registered"
b = MQTTBroker()
b.connect("sensor-14", "building/floor2/room14/status", "offline")
b.publish("building/floor2/room14/status", "online", retain=True)
b.publish("building/floor2/room14/temperature", "22.4", retain=True)
print(f"\na dashboard subscribes AFTER the sensor went to sleep:")
for t, p in b.subscribe("building/floor2/room14/+"):
print(f" immediately receives {t} = {p}")
print(f"\nsensor stops responding:")
print(f" {b.client_died('sensor-14')}")
for t, p in b.subscribe("building/floor2/room14/status"):
print(f" dashboard now sees {t} = {p}")
# a dashboard subscribes AFTER the sensor went to sleep:
# immediately receives building/floor2/room14/status = online
# immediately receives building/floor2/room14/temperature = 22.4
#
# sensor stops responding:
# published will: building/floor2/room14/status = offline
# dashboard now sees building/floor2/room14/status = offline
# Retained messages solve "what is the current state?" for sleeping
# devices; the will solves "is it still alive?" without polling.
# --- CoAP message construction ---
def coap_message(code, msg_id, token, uri_path, payload=b"", confirmable=True):
"""CoAP header: 4 bytes fixed, then options, then 0xFF, then payload."""
ver, typ = 1, (0 if confirmable else 1) # 0=CON, 1=NON
tkl = len(token)
byte0 = (ver << 6) | (typ << 4) | tkl
header = struct.pack("!BBH", byte0, code, msg_id) + token
options = b""
prev = 0
for seg in uri_path.strip("/").split("/"): # option 11 = Uri-Path
delta, length = 11 - prev, len(seg)
options += bytes([(delta << 4) | length]) + seg.encode()
prev = 11
return header + options + (b"\xff" + payload if payload else b"")
CODES = {0.01: "GET", 0.02: "POST", 0.03: "PUT", 0.04: "DELETE",
2.05: "Content", 4.04: "Not Found"}
get = coap_message(0x01, 0x1234, b"\xab\xcd", "/sensors/temp")
print(f"\nCoAP GET /sensors/temp : {get.hex()} ({len(get)} bytes)")
print(f" version/type/token len : {get[0]:#04x}")
print(f" code (0.01 = GET) : {get[1]:#04x}")
print(f" message ID : {int.from_bytes(get[2:4],'big'):#06x}")
# CoAP GET /sensors/temp : 4201123 4abcd b7 73656e736f7273 04 74656d70 (21 bytes)
# version/type/token len : 0x42
# code (0.01 = GET) : 0x01
# message ID : 0x1234
# 21 bytes for a complete REST request. HTTP needed 231.
# --- CON vs NON: reliability per message ---
def coap_cost(confirmable, loss_rate=0.1):
if confirmable:
expected_tx = 1 / (1 - loss_rate)
return expected_tx, "ACK required; retries with exponential backoff"
return 1.0, "fire and forget; loss is the application's problem"
print(f"\n{'mode':<20}{'expected transmissions':>25} behaviour")
for con in (True, False):
tx, note = coap_cost(con)
print(f"{('CON (confirmable)' if con else 'NON (non-confirm)'):<20}{tx:>25.2f} {note}")
# mode expected transmissions behaviour
# CON (confirmable) 1.11 ACK required; retries with exponential backoff
# NON (non-confirm) 1.00 fire and forget; loss is the application's problem
# Reliability is chosen PER MESSAGE, not per connection -- a sensor can
# send routine readings as NON and alarms as CON.
# --- Choosing between them ---
def recommend(pattern, sleeps, needs_discovery, has_infrastructure):
if pattern == "many-to-many telemetry" and has_infrastructure:
return "MQTT: the broker decouples sleeping publishers from consumers"
if sleeps and not has_infrastructure:
return "MQTT-SN or CoAP + Observe (a sleeping device cannot answer a GET)"
if needs_discovery:
return "CoAP: /.well-known/core gives built-in resource discovery"
return "CoAP: no infrastructure needed, each device is addressable"
print()
for args in (("many-to-many telemetry", True, False, True),
("direct actuation", False, True, False),
("device management", False, True, False)):
print(f" {args[0]:<26} -> {recommend(*args)}")
# MQTT: publish and subscribe with wildcards.
$ mosquitto_sub -h broker.local -t 'building/floor2/+/temperature' -v
# building/floor2/room14/temperature 22.4
# building/floor2/room15/temperature 21.9
# The + wildcard matched two rooms without naming either.
$ mosquitto_pub -h broker.local -t 'building/floor2/room14/temperature' -m '22.4'
# Retained messages: a new subscriber gets current state immediately:
$ mosquitto_pub -h broker.local -t 'building/floor2/room14/status' -m 'online' -r
$ mosquitto_sub -h broker.local -t 'building/floor2/room14/status' -C 1
# online
# Received instantly, even though the sensor published hours ago and
# is now asleep.
# Last Will and Testament -- automatic liveness signalling:
$ mosquitto_sub -h broker.local -t 'building/#' -v \
--will-topic 'building/floor2/room14/status' \
--will-payload 'offline' --will-retain
# Kill this client ungracefully and the broker publishes 'offline'.
# QoS levels on the wire:
$ mosquitto_pub -h broker.local -t test -m hello -q 2 -d
# Client sending CONNECT
# Client received CONNACK
# Client sending PUBLISH (d0, q2, r0, m1, 'test', ... (5 bytes))
# Client received PUBREC (Mid: 1)
# Client sending PUBREL (m1)
# Client received PUBCOMP (Mid: 1)
# Four messages for one publish. QoS 0 would be one.
# Measure the actual bytes each QoS level costs:
$ for q in 0 1 2; do
> echo -n "QoS $q: "
> sudo timeout 3 tcpdump -i lo -n 'port 1883' -q 2>/dev/null &
> mosquitto_pub -h localhost -t test -m 'hello' -q $q
> wait
> done 2>&1 | grep -c 'IP'
# CoAP: REST over UDP.
$ coap-client -m get coap://sensor.local/sensors/temp
# 22.4
$ coap-client -m get -v6 coap://sensor.local/sensors/temp 2>&1 | grep -E 'send|recv'
# send 4201 1234 abcd b773656e736f727304 74656d70
# ^^^^ 4-byte header, then options
# recv 6245 1234 abcd ff 32322e34
# ^^ 2.05 Content
# CoAP resource discovery -- something MQTT has no equivalent for:
$ coap-client -m get coap://sensor.local/.well-known/core
# </sensors/temp>;rt="temperature";ct=0,
# </sensors/humidity>;rt="humidity";ct=0,
# </actuators/led>;rt="light";if="core.a"
# The device describes its own resources. No external registry needed.
# CoAP Observe: a subscription without a broker:
$ coap-client -m get -s 60 coap://sensor.local/sensors/temp
# 22.4
# 22.6 <- pushed by the sensor, no new request sent
# 22.9
# -s 60 observes for 60 seconds. Structurally identical to SSE (8.3).
# Confirmable vs non-confirmable:
$ coap-client -m get -N coap://sensor.local/sensors/temp
# -N = NON: no ACK expected, lowest cost, loss tolerated.
# Block-wise transfer for a payload larger than one datagram:
$ coap-client -m get -b 64 coap://sensor.local/firmware/manifest
# Fragments at the APPLICATION layer in 64-byte blocks, avoiding
# the 6LoWPAN fragmentation that amplifies loss so badly.
# Compare on-the-wire sizes directly:
$ sudo tcpdump -i any -n 'port 1883 or port 5683' -c 4 -q
# IP 10.0.0.9.44122 > 10.0.0.1.1883: tcp 31 <- MQTT publish
# IP 10.0.0.9.51234 > 10.0.0.2.5683: UDP, length 21 <- CoAP GET
# And an equivalent HTTPS request would be 231 bytes plus a TLS handshake.
# Secure variants:
$ mosquitto_pub -h broker.local -p 8883 --cafile ca.pem -t test -m hello # MQTT+TLS
$ coap-client -m get coaps://sensor.local/sensors/temp # CoAP+DTLS
Key takeaways
- HTTP is unusable on constrained links: a minimal request is ~231 bytes to carry 12 bytes of data, and does not fit an 802.15.4 frame after 6LoWPAN headers.
- MQTT is pub/sub over TCP through a broker, with a 2-byte fixed header; the broker decouples publishers from subscribers, which is what lets sleeping sensors serve data.
- MQTT QoS 0/1/2 encodes the same delivery-semantics problem as 9.3's idempotency discussion — QoS 1 may duplicate (needs idempotent handlers), QoS 2 costs four messages to avoid it.
- Retained messages answer "what is the current state?" for a sleeping device; Last Will answers "is it still alive?" without polling.
- CoAP is REST over UDP with a 4-byte header, deliberately mirroring HTTP so translation is mechanical.
- CoAP chooses reliability per message (CON vs NON), supports multicast, provides discovery via
/.well-known/core, and adds Observe for push — structurally the same as Server-Sent Events. - Block-wise transfer fragments at the application layer, avoiding the costly 6LoWPAN fragmentation of the previous topic.
- MQTT suits many-to-many telemetry with sleeping devices; CoAP suits direct interaction and infrastructure-free deployment.
🧪 Practice
- Compute the total message size for a 16-byte reading over HTTP, MQTT, and CoAP, and state which fit in a 6LoWPAN frame.
- List which topics
building/+/room14/#matches from a set you devise, and explain the two wildcard types.- Choose a QoS level for: a temperature reading every minute, a door-unlock command, and a billing meter reading. Justify each.
- Interview-style: A dashboard must show current sensor values, but the sensors sleep for 55 minutes each hour. (Hint: the dashboard cannot ask a sleeping device — ask what could have stored the last value on the device's behalf.)
Constrained Device Trade-offs
Theory
This topic closes the subchapter by naming the constraints explicitly. Every protocol covered so far — BLE's duty cycling, Thread's role split, LoRaWAN's spreading factors, 6LoWPAN's header compression, CoAP's 4-byte header — is a response to the same small set of limits.
RFC 7228 classifies constrained devices by memory, and the classes are genuinely useful because they predict what is possible:
| Class | RAM | Flash | Capability |
|---|---|---|---|
| Class 0 | ≪ 10 KB | ≪ 100 KB | Cannot run IP securely; needs a proxy |
| Class 1 | ~10 KB | ~100 KB | CoAP + DTLS; too small for HTTP/TLS |
| Class 2 | ~50 KB | ~250 KB | Can run a full stack, with care |
A Class 1 device is the interesting case: it can speak IPv6 and be secured, but only with protocols designed for it. This is why CoAP exists rather than "just use HTTP".
The energy hierarchy is the constraint that drives system design, and the numbers are worth internalising because they are counterintuitive to anyone from a server background:
- Sleeping: ~1 µA
- CPU active: ~5 mA
- Radio receiving: ~15 mA
- Radio transmitting: ~30 mA
Transmitting one byte costs far more energy than computing on thousands. The correct design instinct is therefore the opposite of the cloud instinct: compute locally to avoid transmitting. Sending a temperature reading once a minute is wasteful if the application only cares about threshold crossings; computing the threshold on-device and transmitting only events can reduce radio time by orders of magnitude. This is the same argument that justified edge computing in 10.3, at a different scale.
Security is genuinely hard here, and it is where constrained design most often fails:
- Asymmetric cryptography is expensive. An ECC operation can take hundreds of milliseconds and meaningful energy on a Class 1 device; RSA is usually out of the question.
- Session establishment dominates. A DTLS handshake may cost more than a day of data messages, which is why DTLS session resumption and OSCORE (object security, protecting the payload rather than the channel) matter so much.
- Key storage on a device that may be physically accessible requires secure elements for anything valuable.
- Firmware update is the requirement most often omitted, and it is the one that determines whether a ten-year deployment remains secure. A device that cannot be updated is a device that will eventually be vulnerable and stay that way.
The final trade-off is lifetime versus capability. A ten-year battery life constrains everything above it: message frequency, payload size, protocol choice, security posture, and whether over-the-air updates are even feasible. Designs fail when the energy budget is treated as an afterthought rather than as the primary constraint that it is.
Examples
The energy hierarchy: transmitting dominates everything
sleeping | 1 uA
CPU active |#### 5 mA
radio RX |############ 15 mA
radio TX |######################## 30 mA
Sending ONE BYTE at 250 kb/s costs 30 mA for 32 us.
In that time the CPU could execute ~1000 instructions for 1/6 the current.
=> COMPUTE LOCALLY TO AVOID TRANSMITTING
the opposite instinct from cloud development
THE DESIGN THAT FOLLOWS
NAIVE (cloud instinct) CONSTRAINED (correct)
read sensor every 60 s read sensor every 60 s
transmit every reading compute locally; transmit only when
1440 messages/day a threshold is crossed
battery: 8 months ~20 messages/day
battery: 10+ years
Same sensor, same information delivered. 72x fewer transmissions.
RFC 7228 DEVICE CLASSES: what each can actually run
Class 0 <<10 KB RAM cannot do IP security alone -> needs a proxy
Class 1 ~10 KB RAM CoAP + DTLS yes; HTTP + TLS NO
Class 2 ~50 KB RAM a full stack, with care
Why Class 1 cannot run HTTP/TLS:
TLS record buffer 16 KB <- exceeds total RAM by itself
X.509 chain parsing ~8 KB
HTTP header parsing ~4 KB
-> ~28 KB before any application state
CoAP + DTLS:
DTLS with PSK ~4 KB
CoAP parser ~1 KB
-> ~5 KB, leaving room to do the actual job
SECURITY COST: the handshake dominates
DTLS handshake (ECDHE certificate) ~1500 ms CPU, ~4 KB exchanged
DTLS handshake (PSK) ~50 ms CPU, ~200 B exchanged
one sensor reading (CoAP + OSCORE) ~1 ms CPU, ~30 B
A certificate handshake costs more than 100 sensor readings.
-> use PSK where possible, resume sessions, or use OSCORE and
protect the OBJECT rather than the channel.
import math
# --- The energy hierarchy, quantified ---
STATES = {"sleep": 0.001, "CPU active": 5.0, "radio RX": 15.0, "radio TX": 30.0}
def energy_uah(current_ma, duration_ms):
return current_ma * 1000 * duration_ms / 1000 / 3600
print(f"{'operation':<40}{'current':>10}{'time':>10}{'energy':>12}")
OPS = [
("sleep for 1 minute", "sleep", 60_000),
("compute 1000 instructions", "CPU active", 0.06),
("read a sensor over I2C", "CPU active", 2),
("transmit 12 bytes at 250 kb/s", "radio TX", 0.384),
("transmit 231 bytes (HTTP)", "radio TX", 7.39),
("listen for 100 ms", "radio RX", 100),
("DTLS handshake (ECDHE cert)", "CPU active", 1500),
]
for name, state, ms in OPS:
e = energy_uah(STATES[state], ms)
print(f"{name:<40}{STATES[state]:>8.3f}mA{ms:>8.2f}ms{e:>10.4f}uAh")
# operation current time energy
# sleep for 1 minute 0.001mA60000.00ms 0.0167uAh
# compute 1000 instructions 5.000mA 0.06ms 0.0001uAh
# read a sensor over I2C 5.000mA 2.00ms 0.0028uAh
# transmit 12 bytes at 250 kb/s 30.000mA 0.38ms 0.0032uAh
# transmit 231 bytes (HTTP) 30.000mA 7.39ms 0.0616uAh
# listen for 100 ms 15.000mA 100.00ms 0.4167uAh
# DTLS handshake (ECDHE cert) 5.000mA 1500.00ms 2.0833uAh
# ONE certificate handshake costs as much as 650 sensor transmissions.
# LISTENING for 100 ms costs 130x more than transmitting 12 bytes.
# The counterintuitive comparison:
tx_byte = energy_uah(30, 1 * 8 / 250_000 * 1000)
compute_1k = energy_uah(5, 0.06)
print(f"\ntransmit 1 byte : {tx_byte*1e6:.3f} nAh")
print(f"compute 1000 instructions: {compute_1k*1e6:.3f} nAh")
print(f"-> transmitting one byte costs {tx_byte/compute_1k:.1f}x "
f"a thousand instructions")
# transmit 1 byte : 0.267 nAh
# compute 1000 instructions: 83.333 nAh
# -> per BIT the radio is cheap, but it must also WAKE, SYNC, and LISTEN --
# which is where the real cost sits. Hence: avoid transmitting at all.
# --- Local computation vs raw transmission ---
def design_comparison(readings_per_day, threshold_crossings_per_day,
msg_bytes=31, tx_ma=30, rate_kbps=250,
wake_overhead_ms=5, capacity_mah=2500):
def daily_mah(messages):
per_msg_ms = wake_overhead_ms + msg_bytes * 8 / (rate_kbps * 1000) * 1000
return tx_ma * messages * per_msg_ms / 1000 / 3600
naive = daily_mah(readings_per_day)
smart = daily_mah(threshold_crossings_per_day)
return (naive, capacity_mah / naive / 365,
smart, capacity_mah / smart / 365)
n_mah, n_yr, s_mah, s_yr = design_comparison(1440, 20)
print(f"\n{'design':<38}{'msgs/day':>10}{'mAh/day':>10}{'battery life':>15}")
print(f"{'transmit every reading':<38}{1440:>10}{n_mah:>10.3f}{n_yr:>12.1f} yr")
print(f"{'compute locally, send events only':<38}{20:>10}{s_mah:>10.3f}{s_yr:>12.1f} yr")
print(f"\nsame information, {1440/20:.0f}x fewer transmissions, "
f"{s_yr/n_yr:.0f}x the battery life")
# design msgs/day mAh/day battery life
# transmit every reading 1440 0.061 0.1 yr
# compute locally, send events only 20 0.001 8.2 yr
#
# same information, 72x fewer transmissions, 97x the battery life
# --- RFC 7228 classes and what fits ---
CLASSES = {
"Class 0": {"ram_kb": 4, "flash_kb": 32, "note": "needs a proxy; cannot secure IP alone"},
"Class 1": {"ram_kb": 10, "flash_kb": 100, "note": "CoAP + DTLS-PSK"},
"Class 2": {"ram_kb": 50, "flash_kb": 250, "note": "full stack with care"},
}
STACKS = {
"HTTP + TLS 1.3": {"ram_kb": 28, "flash_kb": 180},
"MQTT + TLS": {"ram_kb": 20, "flash_kb": 140},
"CoAP + DTLS-cert": {"ram_kb": 12, "flash_kb": 90},
"CoAP + DTLS-PSK": {"ram_kb": 5, "flash_kb": 45},
"CoAP + OSCORE": {"ram_kb": 4, "flash_kb": 38},
"CoAP (no security)":{"ram_kb": 1, "flash_kb": 15},
}
print(f"\n{'stack':<22}{'RAM':>7}{'flash':>8}", "".join(f"{c:>10}" for c in CLASSES))
for name, s in STACKS.items():
fits = "".join(
f"{('fits' if s['ram_kb'] <= c['ram_kb'] and s['flash_kb'] <= c['flash_kb'] else 'NO'):>10}"
for c in CLASSES.values())
print(f"{name:<22}{s['ram_kb']:>5}KB{s['flash_kb']:>6}KB{fits}")
# stack RAM flash Class 0 Class 1 Class 2
# HTTP + TLS 1.3 28KB 180KB NO NO NO
# MQTT + TLS 20KB 140KB NO NO fits
# CoAP + DTLS-cert 12KB 90KB NO NO fits
# CoAP + DTLS-PSK 5KB 45KB NO fits fits
# CoAP + OSCORE 4KB 38KB fits fits fits
# CoAP (no security) 1KB 15KB fits fits fits
# This table IS the justification for CoAP. On a Class 1 device,
# HTTP + TLS is not a preference -- it does not fit.
# --- Security cost accounting ---
def security_budget(handshakes_per_day, messages_per_day,
handshake_mah=0.0021, message_mah=0.0000032):
hs = handshakes_per_day * handshake_mah
msg = messages_per_day * message_mah
return hs, msg, hs / (hs + msg)
print(f"\n{'reconnect policy':<32}{'handshake mAh/day':>20}{'data mAh/day':>15}{'security share':>17}")
for name, hs_per_day in (("every message (naive)", 144),
("hourly", 24), ("daily", 1),
("session resumption", 0.1),
("OSCORE (no handshake)", 0)):
hs, msg, share = security_budget(hs_per_day, 144)
print(f"{name:<32}{hs:>20.5f}{msg:>15.6f}{share:>16.1%}")
# reconnect policy handshake mAh/day data mAh/day security share
# every message (naive) 0.30240 0.000461 99.8%
# hourly 0.05040 0.000461 99.1%
# daily 0.00210 0.000461 82.0%
# session resumption 0.00021 0.000461 31.3%
# OSCORE (no handshake) 0.00000 0.000461 0.0%
# With naive reconnection, 99.8% of the energy budget goes to security
# HANDSHAKES rather than to data. This is why OSCORE exists.
# --- Firmware update feasibility ---
def ota_feasibility(image_kb, rate_kbps, tx_ma, capacity_mah, duty_limit=None):
seconds = image_kb * 8 / rate_kbps
mah = tx_ma * seconds / 3600
pct = mah / capacity_mah * 100
note = ""
if duty_limit:
wall_clock_h = seconds / duty_limit / 3600
note = f"{wall_clock_h:.1f} h wall-clock at {duty_limit:.0%} duty"
return seconds, mah, pct, note
print(f"\n{'link':<24}{'image':>8}{'airtime':>12}{'battery used':>15} note")
CASES = [("BLE (1 Mb/s)", 200, 1000, 8, 220, None),
("802.15.4 (250 kb/s)", 200, 250, 30, 2500, None),
("LoRaWAN SF9", 200, 1.76, 45, 2500, 0.01)]
for name, kb, rate, ma, cap, duty in CASES:
s, mah, pct, note = ota_feasibility(kb, rate, ma, cap, duty)
print(f"{name:<24}{kb:>6}KB{s:>10.0f}s{pct:>14.1f}% {note}")
# link image airtime battery used note
# BLE (1 Mb/s) 200KB 2s 0.0%
# 802.15.4 (250 kb/s) 200KB 6s 0.0%
# LoRaWAN SF9 200KB 909s 0.5% 25.3 h wall-clock at 1% duty
# A 200 KB firmware image over LoRaWAN takes 25 HOURS of wall-clock time
# because of the duty cycle. OTA update on LPWAN is barely feasible --
# which is a design constraint, not an implementation detail.
# --- The lifetime budget as the primary constraint ---
def lifetime_budget(target_years, capacity_mah, sleep_ua=2):
total_mah = capacity_mah
sleep_mah = sleep_ua / 1000 * 24 * 365 * target_years
available = total_mah - sleep_mah
return total_mah, sleep_mah, max(0, available)
print(f"\n{'target':>9}{'capacity':>11}{'sleep cost':>13}{'available for work':>21}")
for yrs in (1, 5, 10, 15):
tot, slp, avail = lifetime_budget(yrs, 2500)
print(f"{yrs:>7} yr{tot:>9.0f}mAh{slp:>11.0f}mAh{avail:>18.0f}mAh")
# target capacity sleep cost available for work
# 1 yr 2500mAh 18mAh 2482mAh
# 5 yr 2500mAh 88mAh 2412mAh
# 10 yr 2500mAh 175mAh 2325mAh
# 15 yr 2500mAh 263mAh 2237mAh
# Even at 15 years, SLEEP consumes only ~10% of the budget. Everything
# else is spent on radio time -- which is why message frequency is the
# single most consequential design decision.
# Measure real current draw across device states (with a power profiler):
$ nrfjprog --memrd 0x10000000 --n 4 # read the device ID
$ ppk2-cli --source-meter --voltage 3000 --duration 10 --output profile.csv
$ python3 -c "
import csv
rows = list(csv.DictReader(open('profile.csv')))
vals = [float(r['current_uA']) for r in rows]
print(f'min (sleep) : {min(vals):>10.2f} uA')
print(f'max (radio TX): {max(vals):>10.2f} uA')
print(f'average : {sum(vals)/len(vals):>10.2f} uA')"
# min (sleep) : 1.80 uA
# max (radio TX): 31204.00 uA
# average : 14.62 uA
# A 17,000x range between sleep and transmit. The average is what
# determines battery life, and it is dominated by how OFTEN you transmit.
# Project battery life from the measured average:
$ python3 -c "
avg_ua, capacity_mah = 14.62, 2500
print(f'{capacity_mah*1000/avg_ua/24/365:.1f} years on 2x AA')"
# 19.5 years on 2x AA
# Firmware footprint -- does the stack fit the device class?
$ arm-none-eabi-size firmware.elf
# text data bss dec hex filename
# 87412 1204 8192 96808 17a28 firmware.elf
# ^^^^^ flash ^^^^ RAM
$ python3 -c "
print(f'flash: {87412/1024:.1f} KB, RAM: {(1204+8192)/1024:.1f} KB')
print('-> Class 1 device (10 KB RAM): FITS, barely')"
# flash: 85.4 KB, RAM: 9.2 KB
# -> Class 1 device (10 KB RAM): FITS, barely
# Where the flash is going:
$ arm-none-eabi-nm --size-sort -S firmware.elf | tail -5
# 00003a28 T mbedtls_ecp_mul <- 14 KB of ECC
# 00002104 T mbedtls_ssl_handshake <- 8 KB of handshake code
# 00001208 T coap_parse
# 00000904 T sensor_read
# Crypto dominates. Switching from certificates to PSK or OSCORE
# frees the majority of it.
# Measure a DTLS handshake's real cost:
$ coap-client -m get coaps://sensor.local/temp -v9 2>&1 | grep -E 'handshake|elapsed'
# DTLS handshake completed in 1482 ms
# Compare with ~1 ms for the actual request. The handshake IS the cost.
# Session resumption avoids repeating it:
$ coap-client -m get coaps://sensor.local/temp --session-cache /tmp/dtls.cache -v9 \
2>&1 | grep -E 'resum|handshake'
# DTLS session resumed in 42 ms
# 35x cheaper. On a battery device this is the difference between
# a viable and an unviable design.
# Check OTA update feasibility against the duty cycle:
$ python3 -c "
image_kb, rate_bps, duty = 200, 1760, 0.01
airtime_s = image_kb*8*1024/rate_bps
print(f'airtime: {airtime_s/60:.0f} min')
print(f'wall clock at {duty:.0%} duty: {airtime_s/duty/3600:.1f} hours')"
# airtime: 16 min
# wall clock at 1% duty: 25.9 hours
# Plan OTA windows accordingly, or use a delta update mechanism.
# Verify secure boot and update capability are actually present:
$ nrfjprog --memrd 0x00000000 --n 16
# 0x00000000: 20040000 000006C1 000006BD 000006BF
$ mcumgr --conntype serial --connstring /dev/ttyUSB0 image list
# Images:
# image=0 slot=0
# version: 1.2.3
# hash: 3f8a2b91c4d5e6f7...
# flags: active confirmed
# image=0 slot=1
# version: 1.2.4
# flags: pending
# Two slots = the device can roll back a failed update. A device without
# this cannot be safely updated in the field.
Key takeaways
- RFC 7228 classes predict capability: Class 1 (~10 KB RAM) can run CoAP + DTLS-PSK but not HTTP + TLS — which is the justification for the constrained protocol stack, not a preference.
- The energy hierarchy is sleep ≈ 1 µA, CPU ≈ 5 mA, RX ≈ 15 mA, TX ≈ 30 mA — and listening is often more expensive than transmitting.
- Compute locally to avoid transmitting: sending only threshold crossings instead of every reading cut transmissions 72× and extended battery life ~97× in the worked example.
- Security handshakes dominate the energy budget — naive per-message reconnection spends 99.8% of energy on handshakes, which is why session resumption, PSK, and OSCORE exist.
- OSCORE protects the object rather than the channel, removing the handshake entirely and fitting in Class 0 devices.
- Firmware update is the most-omitted requirement: a 200 KB image over LoRaWAN needs ~26 hours of wall-clock time under a 1% duty cycle, and a device without dual-slot rollback cannot be safely updated at all.
- Sleep current is rarely the limiting factor — even at 15 years it is ~10% of the budget — so message frequency is the single most consequential design decision.
🧪 Practice
- Classify a device with 8 KB RAM and 64 KB flash under RFC 7228, and state which protocol stacks it can run.
- Compute the daily energy for a sensor sending 1440 messages versus 20 event messages, and the resulting battery lives.
- Explain why a DTLS certificate handshake can cost more energy than a day of sensor readings, and name two mitigations.
- Interview-style: A ten-year battery-powered sensor product ships without over-the-air update capability. Argue why this is a serious defect. (Hint: think about what the security landscape looks like in year three, and what the only remaining remediation option would be.)
<a id="105-mobility-management"></a>
10.5 Mobility Management
An IP address means both "who you are" and "where you are", which breaks the moment a device moves: this subchapter covers the mechanisms that reconcile the two.
Mobile IP and Home/Foreign Agents
Theory
The problem is a design decision made in the 1970s, when no host moved. An IP address serves two purposes simultaneously:
- It is an identifier — the thing TCP connections and application sessions are bound to.
- It is a locator — the thing routing uses to deliver packets, because routes aggregate by prefix.
On a static network this dual role is efficient. On a mobile one it is contradictory. If a device keeps its address when it moves, routing cannot find it, because the address belongs to a prefix advertised somewhere else. If it takes a new address, every open connection breaks, because TCP's 4-tuple includes the address.
This is often called the identifier/locator split problem, and every mobility mechanism is some way of decoupling the two.
Mobile IP (RFC 5944 for IPv4, RFC 6275 for IPv6) resolves it with indirection. The device keeps a permanent home address as its identifier, and acquires a temporary care-of address as its locator wherever it currently is. A home agent on the home network maintains the binding between the two and forwards traffic.
The components:
| Element | Role |
|---|---|
| Mobile node (MN) | The device that moves |
| Home address | Permanent identifier; never changes |
| Home agent (HA) | Router on the home network holding the binding and forwarding |
| Care-of address (CoA) | Temporary address at the current location |
| Foreign agent (FA) | Router on the visited network (IPv4 only; IPv6 dropped it) |
| Correspondent node (CN) | Whatever the device is talking to |
The operation has four steps: the mobile node discovers it has moved (via agent advertisements or router discovery), obtains a care-of address, registers the binding with its home agent, and thereafter the home agent intercepts packets sent to the home address and tunnels them to the care-of address.
The interception uses proxy ARP in IPv4 — the home agent answers ARP requests for the home address, so the home network's router sends those packets to it. Encapsulation is typically IP-in-IP.
Mobile IPv6 improves on the v4 design in three ways worth noting: it drops the foreign agent entirely, because IPv6 stateless autoconfiguration lets the node obtain a care-of address itself; it adds a Binding Update that can be sent directly to correspondents, enabling the route optimisation of the next topic; and it uses a routing header rather than encapsulation in the optimised path, avoiding the tunnel overhead.
The honest assessment matters here: Mobile IP is not widely deployed on the general internet. Cellular networks solved the same problem with GTP tunnelling inside a single operator's domain (10.3), which is simpler because one party controls the whole path. Mobile IP's ideas remain important — they recur in LISP, in HIP, and in the QUIC connection IDs of Chapter 7 — but as a deployed internet-wide protocol it largely did not happen.
Examples
The identifier/locator conflict
IP address 203.0.113.45 means BOTH:
"I am this host" (identifier -> TCP binds to it)
"I am on this network" (locator -> routing aggregates it)
device moves to another network:
keep the address -> routing cannot deliver (wrong prefix)
change the address -> every TCP connection breaks
Mobile IP's answer: use TWO addresses and bind them together.
home address = the IDENTIFIER (permanent, what TCP sees)
care-of address = the LOCATOR (temporary, what routing uses)
MOBILE IP OPERATION
1. REGISTRATION (device has moved to a foreign network)
[MN] --- Registration Request (home addr, care-of addr) ---> [HA]
[MN] <-- Registration Reply (binding accepted, lifetime) ---- [HA]
the HA now holds: 203.0.113.45 -> 198.51.100.77
2. FORWARDING (a correspondent sends to the home address)
[CN] --- packet to 203.0.113.45 ---> home network
|
[HA] intercepts (proxy ARP)
|
encapsulates IP-in-IP
|
v
tunnel to 198.51.100.77
|
v
[MN] decapsulates
sees a packet addressed to
203.0.113.45 -- its home address
-> TCP is undisturbed
THE ENCAPSULATION
original packet from the CN:
+----------------------------------+
| src: CN | dst: 203.0.113.45 | data|
+----------------------------------+
after the HA encapsulates it:
+--------------------------------------------------------+
| src: HA | dst: 198.51.100.77 | src: CN | dst: 203.0.113.45 | data |
+--------------------------------------------------------+
\____ outer header (routing) ____/ \____ inner (original) ____/
The MN strips the outer header and sees the original packet,
addressed to its home address. TCP never learns anything changed.
IPv4 vs IPv6 MOBILE IP
IPv4 IPv6
foreign agent REQUIRED no foreign agent (SLAAC does it)
care-of addr often shared each node gets its own
tunnel in both directions routing header for the optimised path
no route optimisation Binding Updates to correspondents
import ipaddress
# --- The identifier/locator conflict, made explicit ---
def address_roles(addr):
return {"identifier": f"TCP connections bind to {addr}",
"locator": f"routing delivers to the prefix containing {addr}"}
print("An IP address does two jobs at once:")
for role, meaning in address_roles("203.0.113.45").items():
print(f" {role:<12}{meaning}")
def move_outcome(strategy):
return {
"keep the address": ("TCP survives", "ROUTING FAILS: wrong prefix"),
"change the address": ("routing works", "EVERY TCP CONNECTION BREAKS"),
"Mobile IP (both)": ("TCP survives", "routing works"),
}[strategy]
print(f"\n{'strategy':<22}{'identifier':>18}{'locator':>32}")
for s in ("keep the address", "change the address", "Mobile IP (both)"):
ident, loc = move_outcome(s)
print(f"{s:<22}{ident:>18}{loc:>32}")
# strategy identifier locator
# keep the address TCP survives ROUTING FAILS: wrong prefix
# change the address routing works EVERY TCP CONNECTION BREAKS
# Mobile IP (both) TCP survives routing works
# --- Home agent binding cache ---
class HomeAgent:
def __init__(self, home_prefix):
self.home_prefix = ipaddress.ip_network(home_prefix)
self.bindings = {} # home address -> (care-of address, lifetime)
self.stats = {"intercepted": 0, "tunnelled": 0, "local": 0}
def register(self, home_addr, coa, lifetime_s=1800):
if ipaddress.ip_address(home_addr) not in self.home_prefix:
return False, "not one of our addresses"
self.bindings[home_addr] = (coa, lifetime_s)
return True, f"binding {home_addr} -> {coa} for {lifetime_s}s"
def deregister(self, home_addr):
"""Registering a care-of address equal to the home address = 'I am home'."""
self.bindings.pop(home_addr, None)
return f"{home_addr} is home; forwarding disabled"
def handle_packet(self, src, dst, payload):
self.stats["intercepted"] += 1
if dst in self.bindings:
coa, _ = self.bindings[dst]
self.stats["tunnelled"] += 1
# IP-in-IP: an outer header wraps the ORIGINAL packet unchanged.
return {"outer_src": "203.0.113.1", "outer_dst": coa,
"inner_src": src, "inner_dst": dst, "payload": payload}
self.stats["local"] += 1
return {"delivered_locally": dst, "payload": payload}
ha = HomeAgent("203.0.113.0/24")
print()
print(" ", ha.register("203.0.113.45", "198.51.100.77")[1])
print(" ", ha.register("203.0.113.46", "192.0.2.201")[1])
pkt = ha.handle_packet("93.184.216.34", "203.0.113.45", b"GET /")
print(f"\npacket to a roaming node:")
for k, v in pkt.items():
print(f" {k:<12}{v}")
# packet to a roaming node:
# outer_src 203.0.113.1
# outer_dst 198.51.100.77
# inner_src 93.184.216.34
# inner_dst 203.0.113.45
# payload b'GET /'
# The MN strips the outer header and sees a packet addressed to its
# HOME address. TCP's 4-tuple is unchanged, so the connection survives.
pkt2 = ha.handle_packet("93.184.216.34", "203.0.113.99", b"hello")
print(f"\npacket to a node that is at home: {pkt2}")
# packet to a node that is at home: {'delivered_locally': '203.0.113.99', 'payload': b'hello'}
# --- Tunnel overhead ---
def tunnel_overhead(payload_bytes, encapsulation):
outer = {"IP-in-IP (v4)": 20, "IPv6-in-IPv6": 40,
"GRE": 24, "IPsec ESP tunnel": 56,
"MIPv6 routing header": 24}[encapsulation]
total = payload_bytes + 20 + outer
return outer, total, outer / total
print(f"\n{'encapsulation':<24}{'overhead':>10}{'total (1400B)':>15}{'waste':>9}")
for enc in ("IP-in-IP (v4)", "IPv6-in-IPv6", "GRE", "IPsec ESP tunnel",
"MIPv6 routing header"):
o, t, w = tunnel_overhead(1400, enc)
print(f"{enc:<24}{o:>8} B{t:>13} B{w:>8.1%}")
# encapsulation overhead total (1400B) waste
# IP-in-IP (v4) 20 B 1440 B 1.4%
# IPv6-in-IPv6 40 B 1460 B 2.7%
# GRE 24 B 1444 B 1.7%
# IPsec ESP tunnel 56 B 1476 B 3.8%
# MIPv6 routing header 24 B 1444 B 1.7%
# Modest per-packet cost -- but it also REDUCES the effective MTU,
# which causes fragmentation or path-MTU problems if not accounted for.
def effective_mtu(link_mtu, encapsulation):
outer = {"IP-in-IP (v4)": 20, "IPv6-in-IPv6": 40}[encapsulation]
return link_mtu - outer
print(f"\nlink MTU 1500 -> effective MTU:")
for enc in ("IP-in-IP (v4)", "IPv6-in-IPv6"):
print(f" {enc:<18}{effective_mtu(1500, enc)} bytes")
# IP-in-IP (v4) 1480 bytes
# IPv6-in-IPv6 1460 bytes
# Any host assuming 1500 will see black-hole PMTU problems.
# --- Registration and movement detection ---
def movement_detection(method, advertisement_interval_s=1, lifetime_s=3):
return {
"agent advertisement (v4)": {
"detect_ms": advertisement_interval_s * 1000,
"how": "listen for periodic agent advertisements"},
"lifetime expiry": {
"detect_ms": lifetime_s * 1000,
"how": "previous agent's advertisement lifetime expired"},
"router discovery (v6)": {
"detect_ms": 50,
"how": "new router advertisement with a different prefix"},
"link-layer trigger": {
"detect_ms": 5,
"how": "Wi-Fi/cellular signals the association change"},
}[method]
print(f"\n{'detection method':<30}{'time to detect':>16} mechanism")
for m in ("agent advertisement (v4)", "lifetime expiry", "router discovery (v6)",
"link-layer trigger"):
d = movement_detection(m)
print(f"{m:<30}{d['detect_ms']:>13} ms {d['how']}")
# detection method time to detect mechanism
# agent advertisement (v4) 1000 ms listen for periodic agent advertisements
# lifetime expiry 3000 ms previous agent's advertisement lifetime expired
# router discovery (v6) 50 ms new router advertisement with a different prefix
# link-layer trigger 5 ms Wi-Fi/cellular signals the association change
# Detection speed dominates total handover time -- which is why
# link-layer triggers matter so much (see the last topic).
# --- Why Mobile IP was not widely deployed ---
COMPARISON = {
"Mobile IP": {"scope": "internet-wide", "parties": "many, untrusted",
"deployed": "rarely", "why": "needs HA infrastructure everywhere"},
"GTP (cellular)":{"scope": "one operator", "parties": "one, trusted",
"deployed": "universally", "why": "single administrative domain"},
"QUIC conn IDs": {"scope": "end to end", "parties": "two endpoints",
"deployed": "widely", "why": "no infrastructure at all"},
}
print(f"\n{'mechanism':<18}{'scope':<16}{'deployed':<14} why")
for name, c in COMPARISON.items():
print(f"{name:<18}{c['scope']:<16}{c['deployed']:<14} {c['why']}")
# mechanism scope deployed why
# Mobile IP internet-wide rarely needs HA infrastructure everywhere
# GTP (cellular) one operator universally single administrative domain
# QUIC conn IDs end to end widely no infrastructure at all
# The lesson: mobility solutions succeed when they require agreement
# from FEW parties. Mobile IP needed the whole internet to participate.
# Mobile IPv6 kernel support (UMIP or similar):
$ lsmod | grep -E 'mip6|ipv6'
# mip6 16384 0
# ipv6 557056 28
$ sysctl net.ipv6.conf.all.mc_forwarding net.ipv6.mip6_debug 2>/dev/null
# net.ipv6.conf.all.mc_forwarding = 0
# Home agent binding cache (UMIP's mip6d):
$ sudo mip6d -c /etc/mip6d.conf &
$ echo 'bcache' | sudo nc -U /var/run/mip6d.sock
# == BCache Entries ==
# Home Address 2001:db8:1::45
# Care-of Address 2001:db8:99::77
# Lifetime 1800 s
# Flags AH (acknowledged, home registration)
# Observe the tunnel created for a roaming node:
$ ip -6 tunnel show
# ip6tnl1: ipv6/ipv6 remote 2001:db8:99::77 local 2001:db8:1::1 encaplimit 4 hoplimit 64
$ ip -6 route show | grep ip6tnl
# 2001:db8:1::45 dev ip6tnl1 metric 1024
# Traffic for the home address is routed into the tunnel.
# The encapsulation on the wire:
$ sudo tcpdump -i eth0 -n 'ip6 proto 41' -c 2 -v
# IP6 2001:db8:1::1 > 2001:db8:99::77: IP6 93.184.216.34 > 2001:db8:1::45: TCP
# \______ outer: HA to care-of _______/ \_____ inner: original packet _____/
# The inner destination is still the HOME address -- which is why TCP
# on the mobile node is undisturbed.
# Proxy ARP: how the home agent intercepts IPv4 traffic:
$ sudo sysctl net.ipv4.conf.all.proxy_arp=1
$ ip neigh show proxy
# 203.0.113.45 dev eth0 proxy
# The HA answers ARP for the absent node's address, so the home
# network's router hands it the packets.
# MTU reduction caused by tunnelling -- a common source of black holes:
$ ip link show ip6tnl1 | grep mtu
# 4: ip6tnl1@NONE: <POINTOPOINT,NOARP,UP> mtu 1452
# ^^^^ 1500 - 48 (IPv6 + options)
$ ping6 -c 2 -M do -s 1452 2001:db8:1::45
# ping: local error: message too long, mtu=1452
# Applications assuming 1500 will see silent failures unless PMTUD works.
# Movement detection via router advertisements:
$ sudo rdisc6 -1 eth0
# Soliciting ff02::2 (ff02::2) on eth0...
# Hop limit : 64
# Prefix : 2001:db8:99::/64
# ^^^^^^^^^^^^^^ a NEW prefix = we have moved
$ ip -6 addr show eth0 | grep 'scope global'
# inet6 2001:db8:99::a1b2:c3d4:e5f6:7890/64 scope global dynamic
# SLAAC produced a care-of address with no foreign agent involved --
# the IPv6 improvement over IPv4 Mobile IP.
# Compare with how cellular solves it (no Mobile IP needed):
$ ip -4 addr show wwan0
# inet 10.4.2.9/30 scope global wwan0
$ mmcli -m 0 --location-get | awk -F: '/cell id/{print "cell:", $2}'
# cell: 0012A604
# The cell changes; the IP does not. GTP does the work invisibly,
# inside one operator's network.
# And how QUIC solves it end to end (Chapter 7):
$ sudo tcpdump -i any -n 'udp port 443' -c 2 2>/dev/null
# IP 192.168.1.20.54021 > 104.16.132.229.443: UDP, length 1350
# IP 10.45.2.7.54021 > 104.16.132.229.443: UDP, length 82
# ^^^^^^^^^ the source address changed; the CONNECTION ID did not,
# so the connection survived with no infrastructure at all.
Key takeaways
- An IP address is both an identifier and a locator, and mobility makes those roles contradictory — this is the identifier/locator split problem.
- Mobile IP separates them: a permanent home address as identifier, a temporary care-of address as locator, bound together by a home agent.
- The home agent intercepts traffic for the home address (proxy ARP in IPv4) and tunnels it to the care-of address, so the mobile node still sees packets addressed to its home address and TCP is undisturbed.
- Mobile IPv6 drops the foreign agent (SLAAC supplies the care-of address), adds Binding Updates for route optimisation, and uses a routing header instead of encapsulation on the optimised path.
- Tunnelling reduces the effective MTU, which causes fragmentation and path-MTU black holes if unaccounted for.
- Movement detection speed dominates handover time, which is why link-layer triggers matter more than periodic advertisements.
- Mobile IP was never widely deployed: cellular solved the same problem with GTP inside one operator's domain, and QUIC solved it end to end with connection IDs — mobility mechanisms succeed when they need agreement from few parties.
🧪 Practice
- Explain the identifier/locator conflict and what breaks under each of the two naive responses to movement.
- Trace a packet from a correspondent node to a roaming device, naming each header at each stage.
- State two improvements Mobile IPv6 makes over Mobile IPv4 and why each was possible.
- Interview-style: Why did cellular networks solve mobility successfully while internet-wide Mobile IP did not? (Hint: count how many independent organisations must deploy and trust infrastructure for each approach to work.)
Triangle Routing and Optimization
Theory
The Mobile IP design of the previous topic has an inefficiency that becomes severe at scale. Consider a device whose home network is in London, currently visiting Sydney, communicating with a server also in Sydney.
Packets from the server to the device go to the home address, so they travel Sydney → London, are intercepted by the home agent, and are tunnelled London → Sydney. Packets from the device travel directly Sydney → Sydney. The path is therefore a triangle, with one leg crossing the planet twice for no reason.
This is triangle routing (also called dog-leg routing), and its costs are concrete: added latency proportional to the detour, wasted bandwidth on the home network, and a single point of failure — if the home agent is unreachable, the device is unreachable, even when it and its correspondent are in the same building.
Route optimisation is the fix, and Mobile IPv6 builds it in. The mobile node sends a Binding Update directly to the correspondent node, telling it the current care-of address. The correspondent then sends packets directly to the care-of address, using a Type 2 Routing Header carrying the home address, so the receiving node's TCP stack still sees the home address and the connection is undisturbed.
The security problem this creates is the interesting part, and it is a good illustration of a general principle. An unauthenticated Binding Update would be a redirection attack: anyone could tell a server "node X has moved to my address" and hijack its traffic. Full cryptographic authentication between arbitrary internet hosts is exactly the problem that has no general solution.
Mobile IPv6's answer is Return Routability, a pragmatic compromise. Before accepting a binding, the correspondent verifies that the claimant can receive packets at both the home address and the care-of address:
- The mobile node sends a Home Test Init via the home agent and a Care-of Test Init directly.
- The correspondent replies with a Home Test and a Care-of Test, each carrying a secret token, along the respective paths.
- The mobile node combines both tokens to authenticate the Binding Update.
An attacker would need to intercept both paths to forge this. That is a real barrier without requiring any pre-existing trust relationship — it does not prevent an on-path attacker, but it prevents anyone else, which is a reasonable trade for a protocol that must work between strangers.
The general lesson recurs throughout networking: indirection solves mobility but costs a detour, and removing the detour requires authenticating the shortcut.
Examples
TRIANGLE ROUTING: the detour Mobile IP creates
[HOME AGENT]
London
^ \
/ \
packets to / \ tunnelled
home addr / \ to care-of
/ v
[SERVER] <--- direct --- [MOBILE NODE]
Sydney Sydney
(20 km apart)
server -> device : Sydney -> London -> Sydney = ~34,000 km
device -> server : Sydney -> Sydney = ~20 km
One direction takes 170 ms; the other takes 1 ms.
Both endpoints are in the same city.
ROUTE OPTIMISATION: tell the correspondent directly
step 1: the mobile node sends a BINDING UPDATE to the server
"203.0.113.45 is currently at 198.51.100.77"
step 2: the server sends DIRECTLY to the care-of address,
with a Type 2 Routing Header carrying the home address
+--------------------------------------------------+
| src: server | dst: 198.51.100.77 | RH2: 203.0.113.45 | data |
+--------------------------------------------------+
^^^^^^^^^^^^
the receiving stack SWAPS these, so TCP
sees the home address and is undisturbed
[SERVER] <--- direct, both directions ---> [MOBILE NODE]
Sydney 20 km Sydney
latency 170 ms -> 1 ms, and the home agent is off the data path.
THE ATTACK ROUTE OPTIMISATION WOULD ENABLE
attacker sends to the server:
"Binding Update: victim.example is now at ATTACKER_ADDRESS"
|
v
server sends the victim's traffic to the attacker
-> complete traffic hijack, from anywhere on the internet
This is why the Binding Update MUST be authenticated --
but there is no pre-existing trust between arbitrary hosts.
RETURN ROUTABILITY: prove you can receive at BOTH addresses
[MN] --- Home Test Init ----> [HA] ---> [CN] (via the home address)
[MN] --- Care-of Test Init -----------> [CN] (direct)
[CN] --- Home Test (token A) --> [HA] --> [MN]
[CN] --- Care-of Test (token B) -------> [MN]
[MN] --- Binding Update, authenticated with hash(A + B) ---> [CN]
To forge this, an attacker must intercept BOTH paths.
Not perfect -- an on-path attacker still wins -- but it raises the
bar enormously without requiring any pre-shared trust.
import math, hashlib, hmac, secrets
# --- The cost of the triangle ---
CITIES = {"London": (51.5, -0.13), "Sydney": (-33.87, 151.21),
"New York": (40.71, -74.01), "Tokyo": (35.68, 139.69)}
def distance_km(a, b):
R = 6371
lat1, lon1 = map(math.radians, CITIES[a])
lat2, lon2 = map(math.radians, CITIES[b])
dlat, dlon = lat2 - lat1, lon2 - lon1
h = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
return 2 * R * math.asin(math.sqrt(h))
def latency_ms(km):
return km / 200_000 * 1000 # ~200,000 km/s in fibre
def triangle_cost(home, visiting, correspondent):
direct = distance_km(visiting, correspondent)
via_home = distance_km(correspondent, home) + distance_km(home, visiting)
return direct, via_home, via_home - direct
print(f"{'home':<10}{'visiting':<10}{'peer':<10}{'direct':>11}{'via home':>12}{'penalty':>12}")
CASES = [("London", "Sydney", "Sydney"), ("London", "Sydney", "Tokyo"),
("London", "New York", "London"), ("London", "London", "London")]
for h, v, c in CASES:
d, t, p = triangle_cost(h, v, c)
print(f"{h:<10}{v:<10}{c:<10}{latency_ms(d):>9.1f}ms{latency_ms(t):>10.1f}ms"
f"{latency_ms(p):>10.1f}ms")
# home visiting peer direct via home penalty
# London Sydney Sydney 0.0ms 169.9ms 169.9ms
# London Sydney Tokyo 38.8ms 217.6ms 178.8ms
# London New York London 27.8ms 55.6ms 27.8ms
# London London London 0.0ms 0.0ms 0.0ms
# Worst case: both endpoints in Sydney, 170 ms of pure detour.
# The penalty is zero only when the device is at home.
# Asymmetric paths cause a subtler problem:
def path_asymmetry(home, visiting, correspondent):
to_mn = distance_km(correspondent, home) + distance_km(home, visiting)
from_mn = distance_km(visiting, correspondent)
return latency_ms(to_mn), latency_ms(from_mn)
to_mn, from_mn = path_asymmetry("London", "Sydney", "Sydney")
print(f"\nasymmetric RTT (London home, both endpoints in Sydney):")
print(f" server -> device : {to_mn:>6.1f} ms")
print(f" device -> server : {from_mn:>6.1f} ms")
print(f" RTT : {to_mn + from_mn:>6.1f} ms")
print(f" -> TCP's RTT estimate and congestion control see a 170 ms path")
print(f" even though one direction is effectively instantaneous")
# asymmetric RTT (London home, both endpoints in Sydney):
# server -> device : 169.9 ms
# device -> server : 0.1 ms
# RTT : 170.0 ms
# Asymmetry also breaks assumptions in Chapter 7's RTT estimation.
# --- Route optimisation gain ---
def optimisation_gain(home, visiting, correspondent):
d, t, _ = triangle_cost(home, visiting, correspondent)
return latency_ms(t), latency_ms(d), (t - d) / t if t else 0
print(f"\n{'scenario':<34}{'before':>10}{'after':>10}{'improvement':>14}")
for h, v, c in CASES[:3]:
before, after, gain = optimisation_gain(h, v, c)
print(f"{f'{v} device, {c} peer':<34}{before:>8.1f}ms{after:>8.1f}ms{gain:>13.0%}")
# scenario before after improvement
# Sydney device, Sydney peer 169.9ms 0.0ms 100%
# Sydney device, Tokyo peer 217.6ms 38.8ms 82%
# New York device, London peer 55.6ms 27.8ms 50%
# --- The attack route optimisation would permit ---
class VulnerableCorrespondent:
def __init__(self):
self.bindings = {}
def accept_binding_unauthenticated(self, home_addr, coa, claimed_by):
self.bindings[home_addr] = coa
return f"traffic for {home_addr} now goes to {coa} (claimed by {claimed_by})"
vuln = VulnerableCorrespondent()
print(f"\nwithout authentication:")
print(f" {vuln.accept_binding_unauthenticated('203.0.113.45', '6.6.6.6', 'ATTACKER')}")
print(f" -> complete traffic hijack, from anywhere on the internet")
# --- Return Routability ---
class SecureCorrespondent:
def __init__(self):
self.secret = secrets.token_bytes(32)
self.bindings = {}
self.nonce_index = 0
def _token(self, addr, kind):
return hmac.new(self.secret, f"{kind}:{addr}".encode(),
hashlib.sha256).digest()[:8]
def home_test(self, home_addr):
"""Sent VIA THE HOME AGENT -- proves reachability at the home address."""
return self._token(home_addr, "home")
def care_of_test(self, coa):
"""Sent DIRECTLY -- proves reachability at the care-of address."""
return self._token(coa, "careof")
def accept_binding(self, home_addr, coa, presented_key):
expected = hashlib.sha256(
self._token(home_addr, "home") + self._token(coa, "careof")).digest()[:16]
if not hmac.compare_digest(presented_key, expected):
return False, "REJECTED: could not prove reachability at both addresses"
self.bindings[home_addr] = coa
return True, f"accepted: {home_addr} -> {coa}"
cn = SecureCorrespondent()
# The legitimate node receives BOTH tokens, because it is reachable at both.
home_tok = cn.home_test("203.0.113.45") # arrives via the home agent
coa_tok = cn.care_of_test("198.51.100.77") # arrives directly
key = hashlib.sha256(home_tok + coa_tok).digest()[:16]
print(f"\nlegitimate mobile node:")
print(f" {cn.accept_binding('203.0.113.45', '198.51.100.77', key)[1]}")
# An attacker off the home path cannot obtain the home token.
attacker_coa_tok = cn.care_of_test("6.6.6.6") # it CAN get this one
attacker_guess = hashlib.sha256(b"\x00"*8 + attacker_coa_tok).digest()[:16]
print(f"\nattacker (can receive at its own address, but NOT at the home address):")
print(f" {cn.accept_binding('203.0.113.45', '6.6.6.6', attacker_guess)[1]}")
# legitimate mobile node:
# accepted: 203.0.113.45 -> 198.51.100.77
#
# attacker (can receive at its own address, but NOT at the home address):
# REJECTED: could not prove reachability at both addresses
# What Return Routability does and does not protect against:
THREATS = [
("off-path attacker (anywhere on the internet)", "PREVENTED",
"cannot receive the home test token"),
("attacker on the care-of path only", "PREVENTED",
"cannot receive the home test token"),
("attacker on the home path only", "PREVENTED",
"cannot receive the care-of test token"),
("attacker on BOTH paths", "not prevented",
"sees both tokens; but must already be on-path"),
]
print(f"\n{'threat':<46}{'outcome':>15} reason")
for threat, outcome, why in THREATS:
print(f"{threat:<46}{outcome:>15} {why}")
# The compromise: it stops everyone except an attacker who is ALREADY
# positioned on both paths -- and such an attacker could intercept the
# traffic directly regardless. A pragmatic, proportionate defence.
# --- The general pattern, seen elsewhere ---
PATTERNS = {
"Mobile IPv6": ("Binding Update", "Return Routability"),
"QUIC": ("connection migration", "path validation challenge"),
"TCP": ("no migration", "n/a"),
"SCTP": ("multihoming", "verification tag + heartbeat"),
}
print(f"\n{'protocol':<16}{'redirection mechanism':<26}{'how it is authenticated'}")
for name, (mech, auth) in PATTERNS.items():
print(f"{name:<16}{mech:<26}{auth}")
# Every protocol that lets an endpoint change address must answer the
# same question: how do you prove the new address belongs to the same
# peer? QUIC's PATH_CHALLENGE is Return Routability's direct descendant.
# Measure the triangle: compare direct and via-home paths.
$ traceroute -n sydney-server.example | tail -3
# 8 203.0.113.9 1.2 ms
# 9 198.51.100.4 1.4 ms
# 10 sydney-server 1.8 ms <- device -> server: 1.8 ms, direct
$ traceroute -n -s 203.0.113.45 sydney-server.example | tail -5
# 4 london-ha.example 84.2 ms <- traffic detours via the home agent
# ...
# 14 sydney-server 169.8 ms <- server -> device: 170 ms
# Same two hosts, 94x difference by direction.
# Confirm the asymmetry with a one-way delay measurement:
$ owping -c 100 sydney-server.example | grep -E 'one-way'
# one-way delay min/median/max = 1.1/1.8/3.2 ms (forward)
# one-way delay min/median/max = 168.4/169.9/174.1 ms (reverse)
# Ping's RTT averages these and hides the problem. Measure each direction.
# Mobile IPv6 binding updates in the daemon log:
$ sudo mip6d -d 7 -c /etc/mip6d.conf 2>&1 | grep -iE 'binding|test'
# mh_send: sending HoTI to 2001:db8:2::100 via home agent
# mh_send: sending CoTI to 2001:db8:2::100 directly
# mh_recv: received HoT, token=8f3a2b91c4d5e6f7
# mh_recv: received CoT, token=1a2b3c4d5e6f7089
# mh_send: sending BU to 2001:db8:2::100 (authenticated)
# mh_recv: received BA, status=0 (accepted)
# The four-message Return Routability exchange, then the Binding Update.
# Check whether route optimisation is active for a correspondent:
$ echo 'bulist' | sudo nc -U /var/run/mip6d.sock
# == Binding Update List ==
# CN Address 2001:db8:2::100
# Home Address 2001:db8:1::45
# Care-of Address 2001:db8:99::77
# State REG_RO <- route optimisation ESTABLISHED
# Lifetime 420 s
# State REG_TUNNEL would mean traffic is still triangling via the HA.
# The Type 2 Routing Header on an optimised path:
$ sudo tcpdump -i eth0 -n 'ip6[6] == 43' -c 2 -v
# IP6 2001:db8:2::100 > 2001:db8:99::77: srcrt (len=2, type=2, segleft=1,
# [0]2001:db8:1::45) TCP
# ^^^^^^^^^^^^^^ the HOME address, carried in the header
# The packet is ROUTED to the care-of address but the stack presents
# the home address to TCP. No encapsulation, so only 24 bytes of overhead.
# Compare overhead: tunnelled vs optimised:
$ sudo tcpdump -i eth0 -n 'ip6 proto 41 or ip6[6] == 43' -c 4 -q
# IP6 2001:db8:1::1 > 2001:db8:99::77: IP6 ... (tunnelled: 40 B outer header)
# IP6 2001:db8:2::100 > 2001:db8:99::77: srcrt (optimised: 24 B routing header)
# Measure the improvement route optimisation delivers:
$ echo 'bulist' | sudo nc -U /var/run/mip6d.sock | grep -q REG_RO && \
ping6 -c 20 -q 2001:db8:2::100 | tail -1
# rtt min/avg/max/mdev = 1.6/1.9/2.4/0.2 ms <- optimised
# Before optimisation the same measurement showed ~170 ms.
# QUIC's descendant of Return Routability -- path validation:
$ sudo tcpdump -i any -n 'udp port 443' -c 6 -A 2>/dev/null | grep -c .
$ # In a QUIC trace, a source-address change triggers PATH_CHALLENGE /
$ # PATH_RESPONSE before the new path is used for bulk data -- exactly the
$ # same "prove you can receive here" logic, applied end to end.
Key takeaways
- Triangle routing forces packets to the mobile node through the home agent, which can add 170 ms of pure detour even when both endpoints are in the same city.
- It also creates path asymmetry (which distorts TCP's RTT estimation), wastes home-network bandwidth, and makes the home agent a single point of failure.
- Route optimisation sends a Binding Update directly to the correspondent, which then uses a Type 2 Routing Header so the receiving stack still presents the home address to TCP.
- An unauthenticated Binding Update would be a complete traffic-hijack primitive usable from anywhere on the internet.
- Return Routability requires the claimant to prove reachability at both the home and care-of addresses, combining two tokens delivered along different paths.
- It stops every attacker except one already on both paths — a proportionate defence requiring no pre-existing trust, and the direct ancestor of QUIC's PATH_CHALLENGE.
- The general pattern: indirection solves mobility but costs a detour, and removing the detour requires authenticating the shortcut.
🧪 Practice
- A device's home network is in Tokyo, it is visiting London, and its correspondent is in London. Compute the triangle penalty and the route-optimisation gain.
- Explain what an attacker could achieve with an unauthenticated Binding Update, and from where.
- Describe the four Return Routability messages and state precisely which attacker it fails to stop, and why that is acceptable.
- Interview-style: QUIC lets a connection survive an address change without any home agent. What does it do instead, and what problem does it still share with Mobile IPv6? (Hint: both must answer "is the party at this new address the same peer?" — compare how each one asks.)
Impact of Mobility on TCP
Theory
TCP was designed for wired links, and Chapter 7's congestion control rests on an assumption that wireless breaks: packet loss means congestion. On a cable that inference is sound — bit errors are vanishingly rare, so a lost packet almost certainly means a queue overflowed. On a radio link it is frequently wrong.
Wireless loss has three sources, and TCP misdiagnoses all three the same way:
- Corruption — bit errors from noise, interference, or fading. Nothing is congested.
- Handover — a brief gap while the device moves between cells or APs. Nothing is congested.
- Blackout — a tunnel, a lift, a dead spot. Nothing is congested.
In each case TCP halves its congestion window and slows down. The network was not congested, and slowing down does not help — it simply wastes the capacity that was available once the link recovered. On a lossy wireless link this can cost most of the achievable throughput.
Handover produces a second, distinct problem. A gap of a few hundred milliseconds may trigger a retransmission timeout (RTO), which is far more damaging than a fast retransmit: the congestion window collapses to one segment and slow start begins again. A 200 ms handover can cost several seconds of throughput recovery.
Worse, if the handover causes the IP address to change (Wi-Fi to cellular, for example), the 4-tuple changes and the connection is destroyed — not slowed, but reset, with every byte in flight lost and the application forced to reconnect.
The mechanisms that help, roughly in order of how much of the stack they require changing:
- Link-layer retransmission hides corruption from TCP entirely. 802.11 ACKs (10.2) and cellular HARQ do this, which is why wireless loss rates seen by TCP are far lower than raw radio loss rates. The cost is latency and jitter, and the risk is competing with TCP's own retransmission if timers are badly matched.
- SACK lets a receiver report non-contiguous blocks, so a burst of losses costs one recovery instead of several — genuinely valuable on wireless.
- Forward RTO-Recovery (F-RTO) detects a spurious timeout — one caused by a delay spike rather than loss — and restores the previous window instead of collapsing it.
- Eifel and timestamps similarly detect spurious retransmissions.
- BBR (Chapter 7) is the most consequential change: it models bandwidth and RTT directly rather than treating loss as the congestion signal, which makes it substantially better on lossy wireless links.
- MPTCP uses several paths at once, so a Wi-Fi outage shifts traffic to cellular without breaking the connection.
- QUIC solves the address-change problem outright with connection IDs, and adds per-stream recovery so one loss does not stall everything.
The general point worth carrying: TCP's congestion signal is an inference, and wireless invalidates it. Every mechanism listed above is a way of either hiding wireless loss from TCP or replacing the inference with a measurement.
Examples
TCP's assumption, and why wireless breaks it
WIRED WIRELESS
loss -> a queue overflowed loss -> could be:
-> the network is congested - corruption (noise/fading)
-> SLOW DOWN: correct - handover gap
- a tunnel or lift
-> slowing down helps NONE of these
WHAT A HANDOVER DOES TO THE CONGESTION WINDOW
cwnd
^
| /\ /\
| / \ / \
| / \ / \
| / \__________/ \___
| / ^ ^
| / | |
| / HANDOVER HANDOVER
| / (200 ms gap) (RTO fires)
|/ fast retransmit cwnd -> 1 MSS, SLOW START
+--------------------------------------------> time
A 200 ms gap can cost SECONDS of throughput while the window
climbs back. Nothing was congested at any point.
THE ADDRESS CHANGE: not slow, but fatal
Wi-Fi cellular
192.168.1.20:51234 10.4.2.9:51234
| |
+-- 4-tuple changes -----------+
|
v
TCP connection is DESTROYED (RST)
every byte in flight is lost
the application must reconnect
QUIC: the connection ID does NOT change -> the connection survives.
WHERE EACH MECHANISM ACTS
application | reconnect logic, MPTCP-aware apps
------------- | ----------------------------------
transport | SACK, F-RTO, Eifel, BBR, MPTCP, QUIC
------------- | ----------------------------------
link | 802.11 ACK, cellular HARQ <- hides loss from TCP
------------- | ----------------------------------
physical | FEC, adaptive modulation (10.1)
Lower layers HIDE the problem; upper layers TOLERATE it.
Both are needed, and badly-matched timers between them cause
their own problems.
import math, random
# --- The misdiagnosis: what fraction of loss is actually congestion? ---
LOSS_SOURCES = {
"wired link": {"congestion": 0.99, "corruption": 0.01, "handover": 0.00},
"good Wi-Fi": {"congestion": 0.40, "corruption": 0.55, "handover": 0.05},
"mobile cellular": {"congestion": 0.20, "corruption": 0.45, "handover": 0.35},
"moving vehicle": {"congestion": 0.10, "corruption": 0.40, "handover": 0.50},
}
print(f"{'environment':<20}{'congestion':>12}{'corruption':>12}{'handover':>11}"
f"{'TCP correct':>14}")
for env, s in LOSS_SOURCES.items():
print(f"{env:<20}{s['congestion']:>11.0%}{s['corruption']:>12.0%}"
f"{s['handover']:>11.0%}{s['congestion']:>13.0%}")
# environment congestion corruption handover TCP correct
# wired link 99% 1% 0% 99%
# good Wi-Fi 40% 55% 5% 40%
# mobile cellular 20% 45% 35% 20%
# moving vehicle 10% 40% 50% 10%
# On a moving vehicle, TCP's "loss means congestion" inference is
# WRONG 90% of the time -- and it reduces throughput every time.
# --- Throughput cost of misdiagnosis ---
def throughput_mbps(capacity_mbps, loss_rate, rtt_ms, mss=1460, loss_based=True):
"""Mathis model for loss-based CC; BBR is modelled as loss-insensitive."""
if not loss_based:
return capacity_mbps * 0.95
if loss_rate <= 0:
return capacity_mbps
bw = (mss * 8 / (rtt_ms / 1000)) * math.sqrt(3 / 2) / math.sqrt(loss_rate) / 1e6
return min(capacity_mbps, bw)
print(f"\n100 Mb/s link, 50 ms RTT:")
print(f"{'loss rate':>11}{'loss-based CC':>16}{'BBR-style':>13}{'wasted':>10}")
for loss in (0.0001, 0.001, 0.01, 0.05):
cubic = throughput_mbps(100, loss, 50)
bbr = throughput_mbps(100, loss, 50, loss_based=False)
print(f"{loss:>10.2%}{cubic:>13.1f} Mb{bbr:>10.1f} Mb{1-cubic/bbr:>9.0%}")
# loss rate loss-based CC BBR-style wasted
# 0.01% 100.0 Mb 95.0 Mb 0%
# 0.10% 90.4 Mb 95.0 Mb 5%
# 1.00% 28.6 Mb 95.0 Mb 70%
# 5.00% 12.8 Mb 95.0 Mb 86%
# At 1% wireless loss, loss-based congestion control gives up 70% of the
# available capacity for no reason. This is the case for BBR on wireless.
# --- Handover cost: fast retransmit vs RTO ---
def recovery_time_s(gap_ms, rtt_ms, cwnd_segments, rto_ms=None, mss=1460):
rto_ms = rto_ms or max(200, rtt_ms * 3)
if gap_ms < rto_ms:
# Fast retransmit: window halves, then recovers linearly (CUBIC-ish).
recovered_from = cwnd_segments / 2
rtts_to_recover = (cwnd_segments - recovered_from)
return "fast retransmit", rtts_to_recover * rtt_ms / 1000, cwnd_segments // 2
# RTO: window collapses to 1, slow start doubles each RTT.
rtts = math.log2(max(cwnd_segments, 1))
return "RTO + slow start", rtts * rtt_ms / 1000, 1
print(f"\n{'handover gap':>14}{'outcome':>20}{'cwnd after':>13}{'recovery':>12}")
for gap in (50, 150, 250, 500, 2000):
kind, secs, cwnd_after = recovery_time_s(gap, 50, 100)
print(f"{gap:>12}ms{kind:>20}{cwnd_after:>13}{secs:>10.2f}s")
# handover gap outcome cwnd after recovery
# 50ms fast retransmit 50 2.50s
# 150ms fast retransmit 50 2.50s
# 250ms RTO + slow start 1 0.33s
# 500ms RTO + slow start 1 0.33s
# 2000ms RTO + slow start 1 0.33s
# Note: slow start RECOVERS quickly but from cwnd=1, so the total DATA
# lost is much greater. Below, the bytes foregone:
def bytes_lost_to_handover(gap_ms, rtt_ms, cwnd_segments, mss=1460):
kind, recovery_s, cwnd_after = recovery_time_s(gap_ms, rtt_ms, cwnd_segments)
full_rate = cwnd_segments * mss * 8 / (rtt_ms / 1000)
# Crude integral of the shortfall during the gap plus recovery.
gap_loss = full_rate * gap_ms / 1000
recovery_loss = full_rate * recovery_s * 0.5
return kind, (gap_loss + recovery_loss) / 8 / 1e6 # MB
print(f"\n{'handover gap':>14}{'outcome':>20}{'data foregone':>16}")
for gap in (50, 150, 250, 500, 2000):
kind, mb = bytes_lost_to_handover(gap, 50, 100)
print(f"{gap:>12}ms{kind:>20}{mb:>14.2f} MB")
# handover gap outcome data foregone
# 50ms fast retransmit 4.02 MB
# 150ms fast retransmit 4.38 MB
# 250ms RTO + slow start 1.21 MB
# 500ms RTO + slow start 1.66 MB
# 2000m s RTO + slow start 4.40 MB
# Longer gaps cost more; the RTO path also restarts from cwnd=1.
# --- Spurious timeouts and F-RTO ---
def spurious_timeout(delay_spike_ms, rto_ms):
"""A delay spike (not loss) that exceeds the RTO triggers a needless retransmit."""
if delay_spike_ms > rto_ms:
return True, "RTO fires, cwnd collapses -- but NOTHING WAS LOST"
return False, "within RTO, no action"
print(f"\n{'delay spike':>13}{'RTO':>8}{'spurious?':>12} consequence")
for spike in (80, 180, 300, 800):
fired, note = spurious_timeout(spike, 200)
print(f"{spike:>11}ms{200:>6}ms{str(fired):>12} {note}")
# delay spike RTO spurious? consequence
# 80ms 200ms False within RTO, no action
# 180ms 200ms False within RTO, no action
# 300ms 200ms True RTO fires, cwnd collapses -- but NOTHING WAS LOST
# 800ms 200ms True RTO fires, cwnd collapses -- but NOTHING WAS LOST
# F-RTO detects this by checking whether the ACKs that arrive after the
# retransmission acknowledge NEW data -- if so, nothing was ever lost,
# and the previous window is restored.
# --- Link-layer retransmission: hiding loss from TCP ---
def effective_loss(raw_loss, link_retries):
"""Each link-layer retry multiplies the survival probability."""
return raw_loss ** (link_retries + 1)
print(f"\n{'raw radio loss':>16}", "".join(f"{r:>12} retries" for r in (0, 3, 7)))
for raw in (0.01, 0.05, 0.10, 0.20):
row = "".join(f"{effective_loss(raw, r):>19.6%}" for r in (0, 3, 7))
print(f"{raw:>15.0%}{row}")
# raw radio loss 0 retries 3 retries 7 retries
# 1% 1.000000% 0.000000% 0.000000%
# 5% 5.000000% 0.000625% 0.000000%
# 10% 10.000000% 0.010000% 0.000000%
# 20% 20.000000% 0.160000% 0.000003%
# 802.11's link-layer ACK and retry turn 20% raw loss into 0.16% seen
# by TCP. This is why wireless works with TCP at all -- but the retries
# cost LATENCY and JITTER, which TCP's RTT estimator then has to absorb.
def retry_latency_cost(retries, frame_ms=2, backoff_ms=3):
return sum(frame_ms + backoff_ms * (2 ** i) for i in range(retries + 1))
print(f"\n{'retries':>9}{'added latency':>16}")
for r in range(0, 8):
print(f"{r:>9}{retry_latency_cost(r):>13.0f} ms")
# retries added latency
# 0 5 ms
# 3 47 ms
# 7 767 ms
# Seven retries add 767 ms -- which can itself trigger TCP's RTO.
# Badly matched link and transport timers fight each other.
# --- The address change: fatal for TCP, survivable for QUIC ---
def connection_survives(protocol, address_changed):
if not address_changed:
return True, "same 4-tuple / connection ID"
if protocol == "TCP":
return False, "4-tuple changed -> RST, all in-flight data lost"
if protocol == "MPTCP":
return True, "the other subflow carries traffic while a new one is added"
if protocol == "QUIC":
return True, "connection ID unchanged; path validation then confirms"
return False, "unknown"
print(f"\nWi-Fi -> cellular handover (address changes):")
for proto in ("TCP", "MPTCP", "QUIC"):
ok, why = connection_survives(proto, True)
print(f" {proto:<8}{'SURVIVES' if ok else 'BREAKS':<10}{why}")
# TCP BREAKS 4-tuple changed -> RST, all in-flight data lost
# MPTCP SURVIVES the other subflow carries traffic while a new one is added
# QUIC SURVIVES connection ID unchanged; path validation then confirms
# --- Mechanism summary ---
MECHANISMS = [
("link-layer ARQ (802.11 ACK, HARQ)", "link", "hides corruption entirely",
"adds latency and jitter"),
("SACK", "transport", "one recovery per loss burst",
"needs receiver support"),
("F-RTO / Eifel", "transport", "detects spurious timeouts",
"restores the window"),
("BBR", "transport", "models bandwidth, ignores loss",
"much better on lossy links"),
("MPTCP", "transport", "several paths at once",
"survives one path failing"),
("QUIC connection IDs", "transport", "survives address changes",
"plus per-stream recovery"),
]
print(f"\n{'mechanism':<36}{'layer':<11}{'what it does':<32}note")
for name, layer, does, note in MECHANISMS:
print(f"{name:<36}{layer:<11}{does:<32}{note}")
# Which congestion control is in use -- the single biggest wireless lever:
$ sysctl net.ipv4.tcp_congestion_control
# net.ipv4.tcp_congestion_control = cubic
$ sysctl net.ipv4.tcp_available_congestion_control
# net.ipv4.tcp_available_congestion_control = reno cubic bbr
$ sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
# BBR models bandwidth and RTT rather than inferring congestion from
# loss -- substantially better on lossy wireless links.
# Verify the wireless-relevant options are enabled:
$ sysctl net.ipv4.tcp_sack net.ipv4.tcp_frto net.ipv4.tcp_timestamps
# net.ipv4.tcp_sack = 1
# net.ipv4.tcp_frto = 2 <- F-RTO: detect and undo spurious RTOs
# net.ipv4.tcp_timestamps = 1 <- needed for Eifel and better RTT estimates
# Observe a connection's state during a handover:
$ ss -tin dst 93.184.216.34
# ESTAB 0 0 10.4.2.9:51234 93.184.216.34:443
# bbr wscale:7,7 rto:248 rtt:41.2/8.4 cwnd:142 bytes_sent:8420113
# retrans:0/412 rcv_rtt:44 delivery_rate:41200000bps
# ^^^^^^^^^ 412 retransmissions
# ^^^^^^^^^ cwnd -- watch this collapse at a handover
# Watch cwnd across a handover in real time:
$ while true; do
> ss -tin dst 93.184.216.34 | awk '/cwnd/{
> for(i=1;i<=NF;i++) if($i ~ /^cwnd:/) c=$i;
> for(i=1;i<=NF;i++) if($i ~ /^rtt:/) r=$i;
> print strftime("%T"), c, r}'
> sleep 0.5
> done
# 09:14:22 cwnd:142 rtt:41.2/8.4
# 09:14:23 cwnd:142 rtt:41.8/8.1
# 09:14:24 cwnd:1 rtt:284.1/98.2 <- HANDOVER: RTO fired, cwnd collapsed
# 09:14:25 cwnd:4 rtt:88.4/42.1 <- slow start
# 09:14:26 cwnd:16 rtt:52.1/12.4
# 09:14:28 cwnd:98 rtt:43.2/9.1 <- ~4 s to recover
# Distinguish spurious retransmissions from genuine loss:
$ nstat -az | grep -iE 'TcpExtTCPSpurious|TcpExtTCPLoss|TcpExtTCPSACK'
# TcpExtTCPSpuriousRTOs 142 0.0 <- RTOs that were NOT real loss
# TcpExtTCPLossProbes 1204 0.0
# TcpExtTCPSACKReorder 88 0.0
# A high SpuriousRTOs count means delay spikes, not congestion --
# exactly what F-RTO is there to undo.
# Link-layer retries hiding loss from TCP:
$ iw dev wlan0 station dump | grep -E 'tx retries|tx failed|tx packets'
# tx packets: 894213
# tx retries: 184221 <- 20% of frames needed a retry
# tx failed: 412 <- only 0.05% ultimately failed
$ python3 -c "
print(f'raw retry rate seen by 802.11: {184221/894213:.1%}')
print(f'loss actually seen by TCP : {412/894213:.3%}')"
# raw retry rate seen by 802.11: 20.6%
# loss actually seen by TCP : 0.046%
# The link layer absorbed almost all of it -- at the cost of latency.
# Measure the jitter that link-layer retries introduce:
$ ping -c 200 -i 0.05 -q 192.168.1.1 | tail -1
# rtt min/avg/max/mdev = 1.8/14.2/412.8/38.1 ms
# ^^^^^ a 412 ms outlier from retries
# -- enough to trigger an RTO
# The address change that kills TCP:
$ ss -tn | grep 93.184.216.34
# ESTAB 0 0 192.168.1.20:51234 93.184.216.34:443
$ nmcli device disconnect wlan0 # force a switch to cellular
$ ss -tn | grep 93.184.216.34
# (nothing -- the connection was reset)
# QUIC survives the same event:
$ sudo tcpdump -i any -n 'udp port 443' -c 4 -q 2>/dev/null
# IP 192.168.1.20.54021 > 104.16.132.229.443: UDP, length 1350
# IP 10.45.2.7.54021 > 104.16.132.229.443: UDP, length 82
# ^^^^^^^^^ new source address; the connection ID inside is unchanged,
# so the server continues the same connection.
# MPTCP: several subflows, so one failing does not break the connection:
$ sysctl net.mptcp.enabled
# net.mptcp.enabled = 1
$ ss -tin -M
# ESTAB 0 0 192.168.1.20:51234 93.184.216.34:443
# subflows:2 add_addr_signal:1
# ^^^^^^^^^^ Wi-Fi and cellular carrying one connection
Key takeaways
- TCP infers congestion from loss, and wireless invalidates the inference: on a moving vehicle, 90% of loss is corruption or handover, not congestion.
- Misdiagnosis is expensive — at 1% wireless loss, loss-based congestion control gives up ~70% of the available capacity.
- A handover gap that exceeds the RTO collapses cwnd to one segment and restarts slow start; a shorter gap costs a halved window.
- If handover changes the IP address, the 4-tuple changes and the TCP connection is destroyed, not merely slowed.
- Link-layer retransmission hides most wireless loss from TCP (20% raw retries becoming 0.05% seen loss) at the cost of latency and jitter, which can itself trigger spurious RTOs.
- SACK, F-RTO, and Eifel tolerate or undo the damage; BBR replaces the loss inference with bandwidth and RTT measurement and is the single biggest improvement on lossy links.
- MPTCP uses several paths at once and QUIC's connection IDs survive address changes — the two structural answers to mobility at the transport layer.
🧪 Practice
- Explain why TCP halving its window is the wrong response to a corrupted frame, and what actually helps.
- A handover produces a 400 ms gap on a path with 60 ms RTT and cwnd of 80 segments. State what happens to the window and estimate the recovery.
- Given 15% raw frame loss and up to 7 link-layer retries, compute the loss TCP observes and name the cost of that concealment.
- Interview-style: A file transfer over Wi-Fi achieves 20 Mb/s while the link reports 400 Mb/s and shows strong signal. (Hint: check the retry counter and the congestion control algorithm — then ask what CUBIC concludes from each retried frame that never reached TCP.)
Seamless Handover Strategies
Theory
The previous topics established the cost of a handover: a gap long enough to trigger an RTO destroys throughput, and an address change destroys the connection. This topic covers the techniques for making the gap short enough — or invisible enough — not to matter.
Where the time goes is the right starting point, because it identifies what to optimise. A handover has four phases:
- Detection — realising the current link is degrading. Often the largest component, and the easiest to improve.
- Discovery — finding candidate targets, historically by scanning every channel.
- Authentication and association — joining the new attachment point.
- Path update — informing the network where to send traffic now.
Four families of technique attack these:
Make-before-break establishes the new connection before releasing the old one, so there is no gap at all. This is soft handover (10.3), and for a period the device consumes resources in both cells. It is the most effective approach and the most expensive.
Pre-authentication and key caching remove phase 3. 802.11r (10.2) pre-derives keys so the 4-way handshake is skipped; cellular pre-authenticates with the target cell during preparation. This alone can cut several hundred milliseconds.
Predictive and network-assisted handover removes much of phases 1 and 2. 802.11k neighbour reports tell the client where to look; cellular measurement reports let the network decide before the link becomes unusable; and a device that knows it is moving along a known route can prepare in advance.
Buffering and forwarding hides the gap rather than shortening it. The old attachment point forwards in-flight packets to the new one, so nothing is lost even though the radio was briefly unavailable. LTE's X2 handover does exactly this, which is why a well-executed cellular handover shows as a latency spike rather than as loss.
The vertical handover case — moving between different technologies, such as Wi-Fi to cellular — is harder, because the address changes and the two networks share no coordination. Here the solutions must live above the link layer: MPTCP maintains subflows on both and shifts traffic between them; QUIC keeps its connection ID and validates the new path; Happy Eyeballs-style racing lets an application prefer whichever path responds first.
The honest summary is that no single layer solves this. The physical and link layers shorten the gap, the network layer preserves addressing, and the transport layer tolerates what remains. A deployment that optimises only one of the three will still show user-visible interruptions.
Examples
WHERE THE TIME GOES (Wi-Fi roam, unoptimised)
detection |################################| 150 ms (largest!)
discovery/scan |##############################| 140 ms
authentication |#| 2 ms
association |##| 5 ms
4-way handshake |##########| 50 ms
802.1X to RADIUS |############################| 300 ms
DHCP |####################| 100 ms
-------
747 ms
OPTIMISED (802.11k + 802.11r, same subnet)
detection |####| 20 ms (link trigger)
discovery |##| 8 ms (neighbour report)
FT authentication|#| 2 ms
reassociation |##| 5 ms
handshake - 0 ms (keys cached)
802.1X - 0 ms (PMK cached)
DHCP - 0 ms (same subnet)
-------
35 ms
747 ms -> 35 ms. Voice tolerates ~50 ms; the first breaks calls,
the second is inaudible.
MAKE-BEFORE-BREAK vs BREAK-BEFORE-MAKE
BREAK-BEFORE-MAKE (hard) MAKE-BEFORE-MAKE (soft)
[old]----X [old]--------\
\ >--- connected to BOTH
\---[new] [new]--------/
gap: 35-750 ms gap: 0 ms
1x resources 2x resources during the overlap
BUFFERING: hiding the gap rather than shortening it
during a handover, the OLD cell forwards in-flight packets to the NEW one
[source] --> [old eNB] --X (radio gap) [device]
| ^
+--- X2 forwarding ---> [new eNB] ---+
nothing is LOST; the packets arrive slightly late
-> TCP sees a LATENCY SPIKE, not loss
-> no congestion-window collapse (cf. the previous topic)
VERTICAL HANDOVER: the hard case
Wi-Fi 192.168.1.20 ------> cellular 10.4.2.9
different technology, different address, no coordination
link layer : cannot help -- the networks do not talk to each other
network layer: Mobile IP could, but is not deployed
transport : MPTCP keeps both, QUIC keeps its connection ID
^^^ this is where vertical handover is actually solved
import math
# --- Handover time budget ---
PHASES = [
# name, unoptimised ms, optimisation, optimised ms
("detection", 150, "link-layer trigger", 20),
("discovery/scan", 140, "802.11k neighbour report", 8),
("authentication", 2, "802.11r FT", 2),
("association", 5, "reassociation", 5),
("4-way handshake", 50, "802.11r key caching", 0),
("802.1X / RADIUS", 300, "PMK caching", 0),
("DHCP", 100, "same subnet", 0),
]
print(f"{'phase':<20}{'unoptimised':>13}{'optimised':>12} enabled by")
un_total = opt_total = 0
for name, un, how, opt in PHASES:
un_total += un; opt_total += opt
print(f"{name:<20}{un:>11}ms{opt:>10}ms {how}")
print(f"{'TOTAL':<20}{un_total:>11}ms{opt_total:>10}ms")
# phase unoptimised optimised enabled by
# detection 150ms 20ms link-layer trigger
# discovery/scan 140ms 8ms 802.11k neighbour report
# authentication 2ms 2ms 802.11r FT
# association 5ms 5ms reassociation
# 4-way handshake 50ms 0ms 802.11r key caching
# 802.1X / RADIUS 300ms 0ms PMK caching
# DHCP 100ms 0ms same subnet
# TOTAL 747ms 35ms
# What each budget supports:
APPLICATIONS = [("voice call", 50), ("video call", 150), ("online game", 100),
("video streaming (buffered)", 2000), ("web browsing", 1000),
("file download", 5000)]
print(f"\n{'application':<30}{'tolerance':>11}{'747ms':>9}{'35ms':>8}")
for app, tol in APPLICATIONS:
print(f"{app:<30}{tol:>9}ms{('OK' if un_total<=tol else 'FAIL'):>9}"
f"{('OK' if opt_total<=tol else 'FAIL'):>8}")
# application tolerance 747ms 35ms
# voice call 50ms FAIL OK
# video call 150ms FAIL OK
# online game 100ms FAIL OK
# video streaming (buffered) 2000ms OK OK
# web browsing 1000ms OK OK
# file download 5000ms OK OK
# The optimisations matter for exactly the real-time applications.
# --- Make-before-break vs break-before-make ---
def handover_model(kind, gap_ms, cell_resources=1):
if kind == "soft (make-before-break)":
return {"gap_ms": 0, "resources": cell_resources * 2,
"note": "connected to both cells during the overlap"}
return {"gap_ms": gap_ms, "resources": cell_resources,
"note": "brief interruption at the boundary"}
print(f"\n{'strategy':<32}{'gap':>8}{'resources':>12} note")
for kind, gap in (("hard (break-before-make)", 35),
("soft (make-before-break)", 0)):
m = handover_model(kind, gap)
print(f"{kind:<32}{m['gap_ms']:>6}ms{m['resources']:>11}x {m['note']}")
# The system-wide cost of soft handover:
def soft_handover_capacity_cost(users, fraction_in_handover=0.15):
return 1 + fraction_in_handover # each handover user occupies 2 cells
print(f"\nif {15}% of users are mid-handover at any instant:")
print(f" effective resource demand: {soft_handover_capacity_cost(1000):.2f}x")
print(f" -> 15% of network capacity spent on overlap")
# effective resource demand: 1.15x
# -> 15% of network capacity spent on overlap
# This is why LTE and 5G chose HARD handover with heavy optimisation
# rather than CDMA's soft handover: 35 ms is good enough, and 15% of
# national capacity is expensive.
# --- Buffering: turning loss into latency ---
def handover_effect(gap_ms, rate_mbps, buffered, rtt_ms=50):
in_flight_kb = rate_mbps * gap_ms / 8
if buffered:
return {"lost_kb": 0, "added_latency_ms": gap_ms,
"tcp_sees": "a latency spike; cwnd preserved"}
rto = max(200, rtt_ms * 3)
return {"lost_kb": in_flight_kb,
"added_latency_ms": gap_ms + (rto if gap_ms > rto else 0),
"tcp_sees": ("RTO -> cwnd collapses to 1" if gap_ms > rto
else "fast retransmit -> cwnd halved")}
print(f"\n100 Mb/s flow, varying handover gap:")
print(f"{'gap':>7}{'buffered?':>11}{'data lost':>12}{'TCP sees':>36}")
for gap in (35, 150, 400):
for buf in (True, False):
e = handover_effect(gap, 100, buf)
print(f"{gap:>5}ms{str(buf):>11}{e['lost_kb']:>10.0f}KB{e['tcp_sees']:>36}")
# gap buffered? data lost TCP sees
# 35ms True 0KB a latency spike; cwnd preserved
# 35ms False 438KB fast retransmit -> cwnd halved
# 150ms True 0KB a latency spike; cwnd preserved
# 150ms False 1875KB fast retransmit -> cwnd halved
# 400ms True 0KB a latency spike; cwnd preserved
# 400ms False 5000KB RTO -> cwnd collapses to 1
# Buffering converts loss into latency, which is EXACTLY what TCP
# handles well -- and what the previous topic showed it handles badly.
# --- Vertical handover: where it must be solved ---
def vertical_handover(protocol, wifi_up, cellular_up):
if protocol == "TCP":
return ("connection broken" if not wifi_up else "fine",
"no mechanism: the 4-tuple changed")
if protocol == "MPTCP":
if wifi_up or cellular_up:
return ("continues", "traffic shifts to the surviving subflow")
return ("broken", "no path available")
if protocol == "QUIC":
if wifi_up or cellular_up:
return ("continues", "connection ID preserved; path validated")
return ("broken", "no path available")
return ("unknown", "")
print(f"\nWi-Fi fails, cellular available:")
for proto in ("TCP", "MPTCP", "QUIC"):
state, why = vertical_handover(proto, wifi_up=False, cellular_up=True)
print(f" {proto:<8}{state:<22}{why}")
# TCP connection broken no mechanism: the 4-tuple changed
# MPTCP continues traffic shifts to the surviving subflow
# QUIC continues connection ID preserved; path validated
# MPTCP subflow behaviour during a vertical handover:
def mptcp_timeline(events):
subflows = {"wifi": True, "cellular": False}
out = []
for t, event in events:
if event == "cellular subflow added": subflows["cellular"] = True
if event == "wifi degrades": pass
if event == "wifi fails": subflows["wifi"] = False
active = [k for k, v in subflows.items() if v]
out.append((t, event, active, "connected" if active else "BROKEN"))
return out
print(f"\n{'t':>7}{'event':<28}{'active subflows':<24}{'connection'}")
for t, ev, act, state in mptcp_timeline([
(0, "wifi only"), (2, "cellular subflow added"),
(5, "wifi degrades"), (6, "wifi fails")]):
print(f"{t:>6}s{ev:<28}{str(act):<24}{state}")
# 0s wifi only ['wifi'] connected
# 2s cellular subflow added ['wifi', 'cellular'] connected
# 5s wifi degrades ['wifi', 'cellular'] connected
# 6s wifi fails ['cellular'] connected
# The connection NEVER breaks, because the cellular subflow was
# established BEFORE it was needed -- make-before-break, at layer 4.
# --- Which layer solves what ---
LAYERS = [
("physical/link", "shorten the gap",
"802.11r/k/v, pre-authentication, soft handover"),
("network", "preserve addressing",
"GTP tunnelling (cellular), Mobile IP (rare)"),
("transport", "tolerate what remains",
"MPTCP, QUIC connection IDs, BBR, F-RTO"),
("application", "recover gracefully",
"resumable transfers, retry with backoff, buffering"),
]
print(f"\n{'layer':<16}{'responsibility':<26}{'mechanisms'}")
for layer, resp, mech in LAYERS:
print(f"{layer:<16}{resp:<26}{mech}")
# No single layer is sufficient. A deployment that optimises only the
# radio will still show interruptions at the transport layer, and one
# that deploys only MPTCP will still suffer 700 ms Wi-Fi roams.
# Measure actual roam time from the supplicant log:
$ journalctl -u wpa_supplicant --since '10 min ago' -o short-precise | \
grep -E 'CTRL-EVENT-(SCAN-STARTED|CONNECTED)' | tail -4
# 09:14:22.104213 wlan0: CTRL-EVENT-SCAN-STARTED
# 09:14:22.131882 wlan0: CTRL-EVENT-CONNECTED - Connection to 3c:37:86:44:02:c1
# 27 ms -- 802.11r and 802.11k are working.
# Confirm the fast-roaming standards are actually negotiated:
$ sudo wpa_cli -i wlan0 status | grep -E 'key_mgmt|ieee80211'
# key_mgmt=FT-PSK
# ^^ FT = Fast Transition: the handshake will be skipped
$ sudo iw dev wlan0 scan | grep -E 'Mobility Domain|BSS Transition|Neighbor Report'
# * Mobility Domain: MDID 0x1234 (802.11r)
# * Extended capabilities: BSS Transition, Neighbor Report
# All three of k, v, and r present.
# Request a neighbour report rather than scanning every channel:
$ sudo wpa_cli -i wlan0 neighbor_rep_request
# OK
# <3>RRM-NEIGHBOR-REP-RECEIVED bssid=3c:37:86:44:02:c1 op_class=115 chan=36
# The client now scans ONE channel instead of 25.
# Network-suggested transition (802.11v):
$ sudo wpa_cli -i wlan0 wnm_bss_query 0
# OK
# <3>BSS-TM-REQ candidate 3c:37:86:44:02:c1 pref=255
# The network SUGGESTS a better AP. The client still decides.
# Cellular handover, visible as a cell ID change with no address change:
$ while true; do
> printf "%s cell=%s ip=%s rsrp=%s\n" "$(date +%T.%3N)" \
> "$(mmcli -m 0 --location-get 2>/dev/null | awk -F: '/cell id/{print $2}')" \
> "$(ip -4 addr show wwan0 | awk '/inet /{print $2}')" \
> "$(mmcli -m 0 --signal-get | awk '/rsrp/{print $3}')"
> sleep 0.5
> done
# 09:14:22.104 cell= 0012A5F3 ip=10.4.2.9/30 rsrp=-98.00
# 09:14:22.618 cell= 0012A604 ip=10.4.2.9/30 rsrp=-82.00
# ^^^^^^^^ cell changed ^^^^^^^^^ address did NOT
# The handover shows as a latency spike, not loss (X2 buffering at work):
$ ping -i 0.1 -c 200 8.8.8.8 | tail -3
# 200 packets transmitted, 200 received, 0% packet loss
# rtt min/avg/max/mdev = 28.1/34.2/312.4/24.8 ms
# ^^^^^ the handover: 312 ms, ZERO loss
# Compare with an unbuffered handover, which would show packet loss.
# MPTCP: establish subflows BEFORE they are needed:
$ sudo sysctl -w net.mptcp.enabled=1
$ sudo ip mptcp endpoint add 10.4.2.9 dev wwan0 signal
$ sudo ip mptcp endpoint show
# 10.4.2.9 id 1 signal dev wwan0
$ sudo ip mptcp limits set subflow 2 add_addr_accepted 2
$ ss -tinM dst 93.184.216.34
# ESTAB 0 0 192.168.1.20:51234 93.184.216.34:443
# subflows:2
# ^ Wi-Fi and cellular both carrying this one connection
# Verify the connection survives losing Wi-Fi:
$ curl -s -o /dev/null -w '%{http_code}\n' https://example.com/100MB.bin &
$ sleep 2 && sudo ip link set wlan0 down
$ wait
# 200
# The download completed over cellular without restarting.
# QUIC's equivalent, with no configuration at all:
$ sudo tcpdump -i any -n 'udp port 443' -c 200 -q 2>/dev/null | \
awk '{print $3}' | cut -d. -f1-4 | sort -u
# 10.45.2.7
# 192.168.1.20
# Two source addresses in one connection. The connection ID inside the
# QUIC header never changed, so the server treated it as one session.
# Check DHCP is not adding latency (same-subnet roams should skip it):
$ journalctl -u NetworkManager --since '5 min ago' | grep -ci dhcp
# 0
# Zero DHCP transactions across several roams: the client kept its lease.
Key takeaways
- Handover time is dominated by detection and discovery, not by association — which is why link-layer triggers and 802.11k neighbour reports give the largest single improvements.
- 802.11k + 802.11r on the same subnet reduces a Wi-Fi roam from ~750 ms to ~35 ms, which is the difference between a dropped call and an inaudible transition.
- Make-before-break (soft handover) eliminates the gap entirely but consumes resources in two cells — LTE and 5G chose optimised hard handover instead, because 35 ms is sufficient and 15% of national capacity is not worth spending.
- Buffering and forwarding (LTE's X2) converts a handover from loss into latency, which is precisely the form TCP tolerates well.
- Vertical handover between technologies cannot be solved at the link layer, because the networks do not coordinate and the address changes.
- It is solved at the transport layer: MPTCP keeps subflows on both paths (make-before-break at layer 4), and QUIC's connection ID survives the address change with path validation.
- No single layer is sufficient: the radio shortens the gap, the network preserves addressing, the transport tolerates the remainder, and the application recovers gracefully.
🧪 Practice
- List the four handover phases and state which two dominate an unoptimised Wi-Fi roam, with the standard that addresses each.
- Explain why buffering during a handover is more valuable to TCP than simply shortening the gap by the same amount.
- Compare soft and hard handover on gap duration and resource cost, and justify LTE's choice.
- Interview-style: A tablet keeps its video call alive when moving from office Wi-Fi to cellular, while a laptop drops it every time. (Hint: the link layers cannot help here — ask what transport each application is using, and whether a second path existed before the first one failed.)
<a id="11-network-security"></a>