Crafting a Minimal TCP Server That Survives Roaming and Handoffs

TCP server surviving Wi-Fi roaming

Here's a scene I watched play out a hundred times when I still taught RF to engineering students: a phone walks out of one classroom and into the hallway, crosses into range of a different access point, and somewhere in that transition, a perfectly good upload just dies. No error message. No graceful close. Just silence, then a spinner, then rage.

I've spent enough years staring at packet captures to know this isn't a bug in the app. TCP was built for endpoints that sit still, and real devices don't sit still. They wander between APs, hop subnets, lose their IP mid-conversation, and expect the software layer to just cope. This piece is my attempt to hand you a server pattern that copes, without dragging in a framework the size of a small moon.

The difference between roaming, handoff, and a plain drop

Roaming means your client stays on the same access point and keeps its IP. Nothing interesting happens here, and nobody writes articles about it. A handoff is when the radio hands you off from one AP to another, same network, maybe even same subnet, but your MAC association changes and there's a gap while the new AP catches up.

The nastiest case is an IP-level change: new subnet, new address, sometimes a whole new interface (goodbye Wi-Fi, hello cellular). TCP identifies a connection by a four-tuple of source IP, source port, destination IP, destination port. Change any piece of that and the old connection is dead weight even if both sides are alive and well.

Failure modes that will bite you if you're not watching for them

Silent stalls are the cruelest. Packets get buffered somewhere at the AP, retransmission timers creep upward exponentially, and your server just waits, patient as a lighthouse keeper, for a reply that's never coming back by that route. Half-open connections are worse in a different way: your server's socket sits in ESTABLISHED, convinced everything's fine, while the client has long since vanished across a subnet boundary. NAT makes it uglier still. Mobile carriers love short NAT mapping lifetimes, and the moment that mapping expires, your outbound-looking "established" session is a ghost.

Then there's the rebind trap: same port, brand new IP, and your accept loop happily spins up a fresh socket that the original peer can't even address anymore. And here's a detail that catches people out constantly: Android and iOS will both report "connected" to Wi-Fi while DHCP hasn't finished, or DNS is unreachable, or a captive portal sits in the way eating every packet.

Design principles worth building a habit around

First: keep the server as stateless as you can stomach. Idempotent operations and small resumable chunks of work survive a reconnect; giant monolithic transactions don't. Second: fail fast, then reconnect with some manners. Don't let a dead peer squat on resources for minutes. Third, and this is the crux of everything that follows: separate the transport from the application state. A TCP socket is plumbing. It's disposable. The session, the thing that actually matters to your business logic, needs to outlive any individual pipe.

Fourth, lean on lightweight heartbeats with application-level acknowledgment. TCP's own keepalive is glacial by default; you want to know in seconds, not minutes, that the far end has gone quiet. Fifth, tune your timers for a link that churns. Wireless isn't wired, and treating it like wired Ethernet with occasional hiccups is how you end up debugging at 2am. If you need something sturdier at the protocol level, Multipath TCP or a VPN tunnel can paper over a lot of this, but that's advanced tooling, not your starting point.

The architecture, stripped to its studs

On the server you want five modest pieces:

- A listener and accept loop, nothing fancy, single-threaded or event-driven depending on your taste.

- A thin per-connection handler that owns just the socket, a small buffer, and a session ID - nothing more.

- A session manager that maps those IDs to actual application state, state that must survive the underlying socket dying and being reborn.

- A reachability monitor tracking heartbeats and last-seen timestamps.

- And a reconnect coordinator whose whole job is deciding if an incoming socket gets reattached to an existing session or treated as new.

The flow: client connects, server accepts, and almost immediately the client sends a session token: either a fresh one, or an existing one it's trying to resume. The server validates and attaches. While serving, keep messages small, idempotent, sequenced, and acknowledged explicitly. When the socket dies, don't tear down the session instantly; give it a grace window, then release.

Reconnection, validation, and not getting hijacked

That grace window, something like 5 to 30 seconds depending on your tolerance, is where stale sockets get reattached using the token and a proof; an HMAC works nicely. Require a fresh challenge-response on every reattachment attempt, so an IP change doesn't become an open invitation for session theft. Rate-limit the reconnection and heartbeat traffic too, because an open reattachment endpoint is a lovely thing for an attacker to pound on. If validation fails, don't just lock the user out, offer a resumable checkpoint or force a clean login.

Socket knobs and OS-level tuning

Shorten your TCP keepalive interval and probe count for mobile clients. Think carefully before reaching for SO_REUSEADDR or SO_REUSEPORT; they solve real problems, but invite port-sharing surprises. Where your platform supports it, TCP_USER_TIMEOUT bounds how long a stalled connection can linger. And use non-blocking sockets with epoll or select so your timeouts actually mean something.

The Wi-Fi realities nobody mentions in the RFC

DHCP takes its own sweet time. Captive portals eat your handshake. IPv6 and IPv4 behave differently enough to matter. NAT mappings expire without warning. When a device crosses from one AP to another, assume the IP will change and build your rebinding around the session token, never the four-tuple. Test this properly: toggle airplane mode mid-transfer, force rapid AP switches, and watch what actually happens instead of what you hoped would happen.

Testing and the numbers that actually tell you something

Pull in tc/netem for loss and latency, iperf for throughput, tcpdump and Wireshark for ground truth. Simulate NAT expiration, delayed DHCP, abrupt disconnects, and reconnection while the server's under load. Track reconnection success rate, mean time to reconnect, and false-positive session reclaims - that last one tells you if your grace window is too generous.

Running it in the wild

Log every attach and detach with its reason. Expose metrics for live sessions, orphaned state, and reconnection latency so you can see trouble before your users report it. Garbage-collect sessions on last-seen timestamps rather than letting them rot forever. Roll this out on your least critical service first, gather real telemetry, then tighten the timers once you trust the data.

A one-line mental model to carry with you: accept the socket, read the token, attach to a session, loop on heartbeats, and let the socket die whenever it wants, because the session doesn't care.

Build it this way and your server stops treating mobility as an edge case. Separate the session from the socket, trust short tokens and heartbeats over TCP's own patience, and roaming stops being a crisis.

← Back to the blog