Dynamic DHCPv4 allocation lends an address for a lease period. Renewal can extend that period; expiry permits reuse. DHCP also supports other allocation policies, so a finite dynamic lease is the scope of this article, not the only configuration DHCP can deliver.

Borrow a number, not own it

A newly initializing client discovers servers, selects an offer, and requests configuration. The acknowledged lease records how long it may use the address. A lease database tracks assignments, but cannot guarantee that every machine follows the protocol: unmanaged static addresses, overlapping pools, lost server state, or unauthorized servers can still create conflicts. An address assignment is not proof of a device’s identity or a substitute for access control.

Renewal keeps the lease alive

During a finite lease the client normally renews with the original server at T1. If it receives no acknowledgment by T2, it broadcasts a rebinding request that other suitable servers may answer. Defaults are half and seven-eighths of the lease duration; servers may provide different valid timers. RFC 2131 recommends random variation to reduce synchronization. The acknowledgment sets expiry from the renewal request’s send time plus the granted duration. Later T1 and T2 deadlines follow the renewed lease, not the original acquisition.

Renewal often preserves the address but does not guarantee permanent identity across reboots, moves, or policy changes. A shorter lease can reclaim departed clients’ addresses sooner while increasing renewal load; a longer lease gives clients more time to survive an outage but holds unused assignments longer. Fixed-address requirements call for an explicit allocation policy, rather than assuming an arbitrarily long dynamic lease will suffice.

Reclaim what is not used

A client can release an address, but a server must also handle clients that disappear silently. Once the lease expires, the client must stop using the address and restart configuration; the server may allocate it again. A returning device may receive its old address or a different one. Deleting a binding administratively does not revoke a live client’s belief that its lease remains valid, so clearing leases is not a safe substitute for resolving conflicts.

When it breaks

Pool exhaustion may leave a discover unanswered. A NAK has context-specific meaning, such as rejecting an inappropriate requested address; it is not a universal “pool full” response. Some clients configure IPv4 link-local addresses after DHCP failure, but this is separate behavior and does not identify the cause. Check the pool, relay path, and server logs before changing capacity or retries.

Very short leases increase renewal load and reduce outage tolerance. Simultaneous client starts can concentrate traffic, but not every fleet renews in lockstep. Review configured timers and retry behavior instead of treating the default fractions as mandatory tuning values. Lengthening new leases also does not retroactively change already acknowledged leases.

A rogue server can supply an unwanted router or resolver. Selecting the first offer is one possible client policy, not a universal rule. The server-identifier option is not cryptographic authentication and can be spoofed. Network controls such as DHCP snooping must trust the correct switch ports and relay path; merely remembering a server identifier does not establish that trust.

Sleep and clock handling can expose stale configuration. A device waking after expiry must reacquire configuration before resuming use, not blindly keep the address or assume it is merely at T2. Inspect elapsed lease time and implementation logs; wall-clock synchronization alone does not prove correct timer behavior. Conflict detection and DECLINE handling remain separate safeguards.

An acknowledged finite lease progresses to T1 renewal with the original server, T2 broadcast rebinding if no acknowledgment, then stopping address use at expiry. A successful acknowledgment resets expiry from request send time plus the granted duration and moves later lease deadlines.
Finite dynamic leases only. Default T1 and T2 fractions are not universal tuning guarantees. The categorical timeline and renewal formula are explanatory; no DHCP packets or complete client state machine were tested.

Check it quickly

Save this as dhcp_lease_sim.py and run python3 dhcp_lease_sim.py. It uses a virtual clock and a simplified in-memory server. Unicast and broadcast are scenario labels, not transmitted packets; it checks timer arithmetic and address ownership, not a complete DHCP client state machine. Requests and acknowledgments are instantaneous here; T2 is truncated to whole seconds.

"""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."""
        assert ip in self.pool, "requested address outside pool"
        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):
        """Extend a current lease; transport and client states are not modeled."""
        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(T1)       # now=100: renewed T1; pretend this attempt got no ACK
    server.tick(T2 - T1)  # now=137: renewed T2, before expiry 150
    check(server.now == 50 + T2, "rebind must use renewed lease origin")
    check(server.renew("c1", offered), "T2 rebinding should be ACKed")
    check(server.leases[offered]["expiry"] == 237, "T2 must extend expiry to 137+100")
    print(f"t={server.now}: c1 T2 broadcast rebind OK expiry=237")
    # 4. Leave without renewing: expiry reclaims the address.
    server.tick(200)  # now=337 > expiry 237
    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 resets expiry to the renewal time plus the lease duration, 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.

An earlier version recorded a Python 3.12/macOS pass, but labeled a request at time 107 as T2 even though the renewed T2 was 137. The corrected sequence passes on Python 3.12.14: times 0, 50, 137, and 337, with renewed expiry 237. A real daemon still requires packet, pool, and client-expiry checks.

Further reading

Sources