When I started building TunTun I assumed I’d use WireGuard like everyone else. Tailscale uses it, most mesh VPNs use it, and it’s great at what it does. But WireGuard is just a tunnel. It takes a packet, encrypts it, and delivers it. It doesn’t know how to find the other machine. It can’t punch through NATs. It doesn’t handle a laptop switching from WiFi to cellular. All of that you build yourself, and that’s where most of the actual work is.
Tailscale spent years building that infrastructure on top of WireGuard: DERP relay servers, NAT traversal with STUN and ICE, a coordination layer, peer discovery. I didn’t have years. So I went looking for something that already solved the hard networking problems and found iroh.
iroh instead of WireGuard
iroh is a Rust library that lets you connect to peers by cryptographic key instead of IP address. You say “connect me to this public key” and it handles the rest.
The tradeoff is real though. I gave up WireGuard’s polished tunnel performance and its dead-simple Noise Protocol handshake. iroh’s QUIC/TLS 1.3 stack is more complex. But I got NAT traversal, relay fallback, peer discovery, and multipath support out of the box. That felt like the right trade for a solo developer.
The TUN device
The whole point of a VPN is that applications shouldn’t know it exists. You want machines to have private IPs and everything just works. SSH, curl, whatever.
A TUN device is a virtual network interface. You create one, assign it an IP like 10.7.0.5, and the kernel routes traffic to it like any real interface. My agent reads packets from the TUN, looks up which peer owns the destination IP, sends the packet through iroh, and the other side writes it into their TUN. Neither application on either end knows about the tunnel.
I used tun-rs for this, which have solid cross-platform support.
Why QUIC datagrams and not streams
This one bit me early. QUIC streams are reliable, they guarantee delivery like TCP. Sounds good until you realize most traffic inside the tunnel is already TCP. Two reliability layers fighting each other is a classic problem called TCP meltdown. When a packet is lost, both layers react, both back off, and throughput collapses. OpenVPN over TCP has the same issue.
QUIC datagrams (RFC 9221) are unreliable, like UDP. Send and forget. The application’s own TCP handles retransmission. This is how WireGuard works too, just encrypted packets over plain UDP.
The real performance bottleneck isn’t encryption
This surprised me the most. I assumed encryption would be the bottleneck. Tailscale’s blog posts on their wireguard-go optimizations proved otherwise. The bottleneck is system calls.
Every packet through a userspace VPN means a read() from the TUN and a sendmsg() to a UDP socket. At 10 Gbps that’s around 830,000 packets per second, each with context-switching overhead. Tailscale’s flame graphs showed more CPU time on UDP sends than on ChaCha20 encryption.
The fix is batching. The Linux TUN driver has supported TSO/GRO since 2008, letting the kernel combine dozens of packets into one large segment before handing it to userspace. One read() instead of 44. Combined with sendmmsg() for multi-message sends, Tailscale made userspace wireguard-go faster than the WireGuard kernel module. 12.4 Gbps on an i5-12400.
The important thing for me: this bottleneck is identical regardless of whether you use WireGuard or QUIC. TUN I/O cost is the same. tun-rs supports offload and shows a 4x throughput jump when it’s enabled. The path to high performance exists, I just haven’t walked all of it yet.
Top comments (0)