DHCP hands out leases with a duration, not perpetual ownership. The lifetime drives renewal and lets the server reclaim addresses the device no longer uses.

Borrow a number, not own it

DHCP does not give a device a permanent address; it lends one for a bounded time. On joining, a client broadcasts to discover a server, and the server offers an address with a lease lifetime defined by the network’s configured lease time. Because the assignment is temporary, the server always knows which addresses are valid and can track them in a pool without guessing. That temporary nature is what makes a large, frequently-changing network manageable: addresses are currency the server can hand out, extend, and take back, rather than addresses a host keeps forever.

Renewal keeps the lease alive

Before the lease expires, the client asks to renew it — first at roughly half the lease interval, again if needed — so a healthy device keeps the same address indefinitely without human intervention. This half-life renewal keeps the address stable across reboots and sessions, which matters for things like voice and some VPN clients that would be disrupted by an address change. The configured lease time is a governance knob: a shorter lease recycles addresses faster but forces more renewal traffic; a longer one keeps addresses stable longer but ties up numbers on devices that left long ago.

Reclaim what is not used

When a lease expires without renewal, the server reclaims the address and can hand it to another host. That is how a laptop that left the building days ago stops holding an address a new device needs. A client that abandoned a lease gets a new address on its next join — fine for transient use, hostile to services that expect a fixed identity. Tuning the lease balances churn against availability: short leases for a crowded or transient network, long ones for a stable internal pool where identifiers matter.

When it breaks

Exhaustion looks like silence. With no free address, a new DISCOVER gets no OFFER or its REQUEST draws a NAK, and the laptop falls back to a 169.254.x.x link-local address. Fix capacity, not retries: enlarge the pool, shorten leases on transient networks, and clear bindings flagged as abandoned after conflicts.

The opposite error is a lease so short it becomes a storm. A five-minute lease in a large office drives half the fleet through T1 renewal every few minutes, with stragglers rebroadcasting at T2, so any server restart meets a synchronized flood. Keep the RFC 2131 defaults of T1 at half and T2 at seven-eighths, lengthen the lease first, and stagger booting clients.

A rogue server wins by answering first. A stray router or malicious host offers a plausible address with the wrong gateway or DNS and steers traffic through itself. Symptoms wander: some leases work, resolution looks strange, and the victims change with each race. Pin the legitimate server identifier, enable DHCP snooping so only trusted ports may answer, and remove the intruder.

Clock skew breaks the quiet contract. Lifetimes travel as relative seconds, but absolute expiry timestamps or a laptop asleep past T1 and T2 can expire early or hold too long. The sleeper wakes still configured with an address the server already gave to someone else. Treat long sleep as a rebind, watch NTP alongside DHCP logs, and keep conflict detection with DECLINE and RELEASE handling.

Check it quickly

Save the following as dhcp_lease_sim.py and run python3 dhcp_lease_sim.py. It uses only the standard library and a virtual clock with no network, and models DISCOVER, OFFER, REQUEST, and ACK with T1 unicast renewal and T2 broadcast rebinding.

"""Simulate a DHCP lease lifecycle on a virtual clock (stdlib only)."""
from __future__ import annotations

LEASE_TIME = 100
T1 = LEASE_TIME // 2          # 50: unicast renewal (RFC 2131 default)
T2 = int(LEASE_TIME * 0.875)  # 87: broadcast rebinding (RFC 2131 default)


class DHCPServer:
    def __init__(self, pool):
        self.pool = list(pool)
        self.leases: dict[str, dict] = {}  # ip -> {"client": id, "expiry": int}
        self.now = 0

    def _free_ip(self):
        for ip in self.pool:
            lease = self.leases.get(ip)
            if lease is None or lease["expiry"] <= self.now:
                return ip
        return None

    def discover_offer(self, client_id):
        """DISCOVER -> OFFER (no state change)."""
        ip = self._free_ip()
        assert ip is not None, f"t={self.now}: pool exhausted, no OFFER for {client_id}"
        return ip

    def request_ack(self, client_id, ip):
        """REQUEST -> ACK and bind the lease."""
        lease = self.leases.get(ip)
        assert lease is None or lease["expiry"] <= self.now or lease["client"] == client_id, \
            f"t={self.now}: {ip} held by another client"
        self.leases[ip] = {"client": client_id, "expiry": self.now + LEASE_TIME}
        return {"ip": ip, "lease_time": LEASE_TIME, "t1": T1, "t2": T2,
                "expiry": self.now + LEASE_TIME}

    def renew(self, client_id, ip, broadcast=False):
        """Renew at T1 (unicast) or T2 (broadcast). Returns True on ACK."""
        lease = self.leases.get(ip)
        if lease is None or lease["client"] != client_id or lease["expiry"] <= self.now:
            return False  # expired: server already reclaimed
        lease["expiry"] = self.now + LEASE_TIME
        return True

    def tick(self, dt):
        self.now += dt
        # Reclaim expired leases so a new client can reuse them.
        for ip, lease in list(self.leases.items()):
            if lease["expiry"] <= self.now:
                del self.leases[ip]


def check(cond, msg):
    assert cond, msg


def main():
    server = DHCPServer(["192.168.1.10", "192.168.1.11"])
    # 1. DORA: client c1 joins.
    offered = server.discover_offer("c1")
    check(offered == "192.168.1.10", f"expected first free IP, got {offered}")
    ack = server.request_ack("c1", offered)
    check((ack["lease_time"], ack["t1"], ack["t2"]) == (100, 50, 87), f"bad timers: {ack}")
    check(ack["expiry"] == 100, f"expiry should be 0+100, got {ack['expiry']}")
    print(f"t={server.now}: c1 ACK {ack['ip']} lease=100 T1=50 T2=87 expiry=100")
    # 2. T1 unicast renewal at half lease.
    server.tick(T1)  # now=50
    check(server.renew("c1", offered), "T1 renewal should be ACKed")
    check(server.leases[offered]["expiry"] == 150, "T1 must extend expiry to 50+100")
    print(f"t={server.now}: c1 T1 unicast renew OK expiry=150")
    # 3. Miss T1, rebind at T2 via broadcast.
    server.tick(T2 - T1)  # now=87; T1 of current lease would be 100
    server.tick(20)       # now=107: past T1 (100), before T2 (137) and expiry (150)
    check(server.renew("c1", offered, broadcast=True), "T2 rebinding should be ACKed")
    check(server.leases[offered]["expiry"] == 207, "T2 must extend expiry to 107+100")
    print(f"t={server.now}: c1 T2 broadcast rebind OK expiry=207")
    # 4. Leave without renewing: expiry reclaims the address.
    server.tick(200)  # now=307 > expiry 207
    check(offered not in server.leases, "expired lease must be reclaimed")
    offered2 = server.discover_offer("c2")
    check(offered2 == offered, f"reclaimed {offered} should be reused, got {offered2}")
    ack2 = server.request_ack("c2", offered2)
    check(ack2["expiry"] == server.now + LEASE_TIME, "new lease expiry must restart")
    # Stale renewal from the departed client must fail.
    check(not server.renew("c1", offered), "stale renewal after expiry must not be ACKed")
    print(f"t={server.now}: c1 expired, c2 ACK {ack2['ip']} expiry={ack2['expiry']}")
    print("PASS: discover/offer/request/ack, T1 renew, T2 rebind, expiry reclaim")


if __name__ == "__main__":
    main()

The script asserts the first free address is offered, the ACK carries a 100-second lease with T1 at 50 and T2 at 87, each renewal extends expiry by a full lease, and the departed address is reclaimed for a second client while the stale renewal fails. Expect four timestamped lines plus a PASS line covering offer, renewal, rebinding, and reclaim.

The assertions passed with Python 3.12 on macOS. The virtual clock proves the state machine, not a production daemon: still verify the real server’s lease time, pool size, authorized server list, and NTP health.

Further reading

Sources