Build the mental model
Hardcoding an address into every host does not survive contact with reality. Networks get renumbered, laptops move between subnets, and two people typing the same address into two machines produce a conflict nobody can debug from the application layer. DHCP moves address assignment to the network itself, so a host arrives knowing nothing and leaves fully configured.
The exchange is DORA. The client, which has no IP and therefore cannot address anyone directly, broadcasts a DISCOVER. Any DHCP server on the segment may reply with an OFFER of a specific address. The client picks one and sends a REQUEST, and here is the part that surprises people: the REQUEST is broadcast, not unicast, even though the client now knows exactly which server it wants. It is broadcast precisely because there may have been several offers. Every server on the segment hears the REQUEST, sees whose address was named, and the losers release the addresses they had tentatively reserved. A unicast REQUEST would leave the other servers holding phantom reservations until they timed out, quietly draining their pools. Finally the chosen server sends an ACK, and that is what actually makes the lease real.
A lease is a time limit, not a gift. At T1, fifty percent of the lease, the client unicasts a renewal to its own server. If that fails, at T2, eighty-seven and a half percent, it gives up on that server and broadcasts a rebind to anyone listening. Only if both fail does it drop the address and start over.
And an address alone is useless. The same exchange also carries the subnet mask, the default gateway, and the DNS resolvers. When a host has an IP but reaches nothing, a wrong DHCP-supplied gateway is a far more likely culprit than the address itself.
THE DORA EXCHANGE WITH TWO SERVERS
----------------------------------
Client (no IP yet) Server A Server B
| | |
|--D-- DISCOVER (bcast) -->| |
|--D-- same broadcast -----|------------------>|
| | |
|<-O-- OFFER .100 ---------| |
|<-O-- OFFER .240 ---------|-------------------|
| | |
| [ client picks A's offer ] |
| | |
|--R-- REQUEST .100 ------>| |
|--R-- ...still a BROADCAST|------------------>|
| | |
| | B hears it lost |
| | and returns |
| | .240 to its pool|
| | |
|<-A-- ACK .100 + mask ----| |
| + gateway + DNS | |
v v v
Why is REQUEST a broadcast?
So the LOSING servers learn they lost. Unicast would leave
B holding a phantom reservation on .240 until it timed out.
Lease timers (lease = 3600s):
T1 = 50% = 1800s renew unicast to Server A
T2 = 87.5% = 3150s rebind broadcast to anyone
T = 100% = 3600s address released, back to DISCOVERConnect it to a real scenario
A common support ticket reads: some people in the office cannot get online, others are fine, and it changes every morning. That intermittency pattern is the signature of a pool that is too small, not of a flaky access point.
First, check whether the affected machine has an address at all. A 169.254.x.x address on Windows, or no address on Linux, means DISCOVER got no usable OFFER: the client is talking and nobody is answering, which is either pool exhaustion or a DHCP relay that is not forwarding broadcasts across a router boundary. Broadcasts do not cross routers, so a client on a different VLAN from the server needs a relay agent configured, and forgetting one is the classic cause of DHCP working on one floor and not the other.
Second, compare the lease time against the number of devices, not the number of people. A /24 with a hundred usable addresses and an eight-hour lease serves far fewer than a hundred people once phones associate, take a lease, and wander off, because each holds an address for eight hours after it left. Shortening the lease on a guest network is usually the entire fix.
The simulation below makes this concrete: a four-address pool, five clients, and the moment a lease expires the address is recycled to the refused client.
Try the working example
import ipaddress
# A small DHCP scope, the kind a home router or a lab VLAN would have.
POOL_START = ipaddress.IPv4Address("192.168.10.100")
POOL_END = ipaddress.IPv4Address("192.168.10.103") # deliberately tiny
LEASE_SECONDS = 3600
# Everything DHCP hands over BESIDES the address. Forgetting that these
# come from DHCP too is why a "wrong gateway" bug looks like a DNS bug.
OPTIONS = {
"subnet_mask": "255.255.255.0",
"router": "192.168.10.1",
"dns": "192.168.10.1, 1.1.1.1",
}
class Scope:
def __init__(self, start, end):
self.addresses = [ipaddress.IPv4Address(int(start) + i)
for i in range(int(end) - int(start) + 1)]
self.leases = {} # address -> (mac, expires_at)
def free(self, now):
taken = {a for a, (_, exp) in self.leases.items() if exp > now}
return [a for a in self.addresses if a not in taken]
def offer(self, mac, now):
# Same client asking again gets the SAME address back if it can.
for addr, (owner, exp) in self.leases.items():
if owner == mac and exp > now:
return addr
available = self.free(now)
return available[0] if available else None
def ack(self, mac, addr, now):
self.leases[addr] = (mac, now + LEASE_SECONDS)
return now + LEASE_SECONDS
scope = Scope(POOL_START, POOL_END)
CLOCK = 0 # hardcoded virtual clock in seconds; no wall-clock calls
clients = ["aa:00:01", "aa:00:02", "aa:00:03", "aa:00:04", "aa:00:05"]
print("scope 192.168.10.100-192.168.10.103 lease " +
str(LEASE_SECONDS) + "s")
print("")
for mac in clients:
offered = scope.offer(mac, CLOCK)
if offered is None:
print(mac + " DISCOVER -> no OFFER (pool exhausted)")
continue
expires = scope.ack(mac, offered, CLOCK)
print(mac + " DISCOVER -> OFFER " + str(offered) +
" -> REQUEST -> ACK expires t=" + str(expires))
print("")
print("gateway=" + OPTIONS["router"] + " mask=" + OPTIONS["subnet_mask"])
print("dns=" + OPTIONS["dns"])
print("")
# T1 is 50% of the lease: the client unicasts a renewal to its server.
# T2 is 87.5%: it gives up on that server and broadcasts to anyone.
print("T1 (renew, unicast) at t=" + str(LEASE_SECONDS // 2))
print("T2 (rebind, bcast) at t=" + str(LEASE_SECONDS * 7 // 8))
print("")
# Now let the first lease expire and watch the address get recycled.
CLOCK = 4000
print("at t=" + str(CLOCK) + " free addresses: " +
str(len(scope.free(CLOCK))))
print("aa:00:05 DISCOVER -> OFFER " + str(scope.offer("aa:00:05", CLOCK)))scope 192.168.10.100-192.168.10.103 lease 3600s
aa:00:01 DISCOVER -> OFFER 192.168.10.100 -> REQUEST -> ACK expires t=3600
aa:00:02 DISCOVER -> OFFER 192.168.10.101 -> REQUEST -> ACK expires t=3600
aa:00:03 DISCOVER -> OFFER 192.168.10.102 -> REQUEST -> ACK expires t=3600
aa:00:04 DISCOVER -> OFFER 192.168.10.103 -> REQUEST -> ACK expires t=3600
aa:00:05 DISCOVER -> no OFFER (pool exhausted)
gateway=192.168.10.1 mask=255.255.255.0
dns=192.168.10.1, 1.1.1.1
T1 (renew, unicast) at t=1800
T2 (rebind, bcast) at t=3150
at t=4000 free addresses: 4
aa:00:05 DISCOVER -> OFFER 192.168.10.1005-minute try-it
Change LEASE_SECONDS to 600 and the second CLOCK value to 700. Do all five clients get an address now? Then have one client send DISCOVER twice and confirm it gets the same address back, and explain why that sticky behaviour is useful.
One important caution
Expecting DHCP to work across a router: DISCOVER is a broadcast, so a client on a different subnet from the server needs a relay agent.
Sizing a pool by headcount rather than by device count and lease length: phones that associate briefly still hold an address for the whole lease after they leave.
RFC 2131 - Dynamic Host Configuration Protocol — Computer Networking