Hello everyone.
Over the past few weeks we have been profiling Chute’s TUN performance on macOS, and what we found surprised us: a single TCP flow through the TUN interface was capped well below what the hardware could handle. Today’s update fixes that, and I want to share the story behind the numbers.
The Problem
A user reported that downloading a large file through Chute on a Mac with a gigabit fiber connection was maxing out at around 25 MB/s, while the same download without Chute hit 90 MB/s. That is a 3.6x gap — not something that can be explained by encryption overhead alone.
We started profiling. The first thing we checked was the proxy protocol itself — was the Shadowsocks server the bottleneck? No, the server was barely breaking a sweat. The TLS layer? No, AES-GCM on modern Apple Silicon runs at multiple gigabytes per second. The rule engine? No, rule matching was taking microseconds per connection.
The bottleneck was in the TUN layer.
What We Found
Chute’s TUN implementation uses lwIP, a lightweight TCP/IP stack designed for embedded systems. lwIP is great for what it was built for — constrained devices with limited RAM — but its default configuration is not tuned for desktop throughput.
Three issues stacked up:
1. TCP window sizes were too small. lwIP’s default TCP receive and send windows were sized for kilobytes, not megabytes. On a high-bandwidth, high-latency link, a small window means the sender spends most of its time waiting for acknowledgements rather than pushing data.
2. Packets were being processed one at a time. The TUN read loop was waking up, reading one packet, processing it, and going back to sleep. On a fast connection, packets arrive in bursts. Processing them in batches amortizes the wakeup cost across many packets.
3. The per-packet overhead was significant. Every packet flowing through the TUN interface went through multiple layers: IP parsing, TCP state tracking, DNS lookup (even for already-resolved addresses), rule matching, and policy selection. Some of this work was redundant for packets belonging to an already-established connection.
The Fixes
TCP window scaling. We bumped lwIP’s TCP window sizes from the default ~8 KB to 64 KB per socket, with a total memory budget of 16 MB across all connections. This one change accounted for roughly 60% of the throughput improvement.
Batched packet I/O. We rewrote the TUN read path to drain the interface in batches. Instead of one read per wakeup, the loop now reads all available packets up to a configurable limit before processing them. This cut per-packet overhead nearly in half.
Connection-level caching. Once a connection is established and a rule decision is made, subsequent packets on the same flow skip rule evaluation entirely. The resolved route is cached on the connection object, so packet 2 through packet 10,000 of the same TCP stream hit a fast path.
lwIP write path refactoring. The TCP send path was moved onto a dedicated queue. Previously, writes from the application layer could interleave with TUN reads, causing lock contention. Separating the paths eliminated that.
The Results
After these changes, the same gigabit fiber connection went from 25 MB/s to over 80 MB/s through Chute. That is a 3.2x improvement, bringing the proxy overhead down to roughly 10% — within the margin of what TLS encryption and the proxy protocol itself consumes.
On iOS, the improvements are more modest in absolute terms — devices have less raw throughput — but proportionally similar. Load times for heavy web pages dropped by 15-30%, and streaming video sees fewer buffering events.
Connection Pooling
Alongside the TUN work, we added connection pooling for proxy protocols. Previously, every new TCP connection to the same proxy server required a fresh TLS handshake. Now, idle connections are pooled and reused. The first connection to a proxy still pays the handshake cost; subsequent connections borrow an existing session.
For applications that open many short-lived connections — web browsers, RSS readers, messaging apps — connection pooling reduces latency by 200-800ms per request. Multiplied across hundreds of requests, the difference is noticeable.
MITM HTTP/2 Upgrade
A side benefit of the performance work: we enabled HTTP/2 support in the MITM engine. Previously, HTTPS decryption was limited to HTTP/1.1, which meant browsers had to downgrade their connections. With HTTP/2 MITM, browsers can use multiplexed streams over a single TLS connection, reducing the connection count and improving page load times.
These improvements are available now for both iOS and macOS. If you have been holding off on enabling MITM decryption because of performance concerns, now is a good time to give it another look.
Thanks.
Chute Devs