Skip to content

Benchmarks

Throughput and latency of the data plane over an emulated internet path, measured with the Docker + netem harness in docker/. To reproduce any row below:

sh
./docker/run-bench.sh                                   # clean broadband
DELAY=80ms JITTER=10ms LOSS=1% RATE=20mbit ./docker/run-bench.sh   # lossy link
OBFS=quic CIPHER=aes-256-gcm ./docker/run-bench.sh      # different cipher/obfs

Method

Two containers (server + client), each with its own TUN device, share one private bridge that stands in for the public internet. Both apply a tc netem qdisc — delay, jitter, loss, bandwidth — to that link. netem shapes egress only, so applying it on each side shapes both directions and the round-trip delay is ≈ 2 × DELAY.

The client then measures, with iperf3 and ping:

  • Tunnel — traffic to the server's in-tunnel address (10.9.0.1), i.e. through TUN → encrypt → UDP → server → decrypt → TUN.
  • Direct (WAN) — the same traffic straight to the server container over the same shaped link, with no tunnel.

Because both paths cross the identical netem qdisc, the gap between the two columns is ShadowVPN's own overhead (AEAD + carrier framing + the 1400-byte tunnel MTU), not the emulated link.

Reading the tables

  • The UDP row reports the offered load (UDP_RATE, default 50M) and the measured loss %; goodput ≈ offered × (1 − loss). On a link narrower than the offered rate, high loss simply means the link is saturated.
  • TCP throughput over a lossy link is a single-flow, RTT-sensitive figure with high run-to-run variance — treat lossy-link TCP numbers as approximate.

Results

Measured on the host below. Absolute numbers are host- and run-dependent; the tunnel-vs-direct ratio is the portable takeaway. Run it on your own hardware for figures that matter to you.

CPUApple M4 (10 cores), arm64
Host OSmacOS 26.5
Docker29.2.1, Compose 5.0.2 (Linux VM)
Build--release (fat LTO, codegen-units = 1), pipelined relay loops, 4 MiB UDP buffers
iperf310 s per stream

Throughput ceiling — RTT ≈ 3 ms, 0 % loss, 4 Gbit/s cap

How fast the tunnel goes when the link is not the limit. This is where the data plane's own ceiling shows, and where decoupling socket I/O from the per-packet crypto (a dedicated reader task feeding a single ordered processor, plus enlarged socket buffers) pays off:

Relay loopsTCP up (Mbit/s)TCP down (Mbit/s)UDP @ 2G loss
serial recv → crypt → send28531742 %
pipelined (reader ‖ processor)1007100337 %
direct (no tunnel), for scale381138210.05 %

The pipeline ~3.2×'s single-flow TCP through the tunnel (≈0.3 → ≈1.0 Gbit/s) by overlapping the receive syscall with decryption and the send. One-way UDP at a 2 Gbit/s offer still sheds ~37 % — past ~1.25 Gbit/s the single crypto+send processor becomes the limit, so a blast like that is the next frontier (parallel crypto workers, at the cost of reordering). Real traffic is TCP, which is the row that moved.

Clean broadband — RTT ≈ 48 ms, 0 % loss, 100 Mbit/s cap

Cipher / obfsDir.RTT (ms)TCP up (Mbit/s)TCP down (Mbit/s)UDP @ 50M loss
chacha20-poly1305 / nonetunnel45.287.688.20.00 %
direct47.492.883.00.00 %
chacha20-poly1305 / quictunnel45.186.186.60.00 %
direct45.177.981.70.00 %
aes-256-gcm / nonetunnel49.773.887.20.00 %
direct47.289.785.70.00 %

At ~100 Mbit/s the tunnel runs at or near line rate — within a few percent of the direct baseline, and effectively at parity within run-to-run noise — because the link, not ShadowVPN, is the bottleneck here. UDP rides at line rate with zero loss; cipher and carrier framing barely move the result.

Cipher / obfsDir.RTT (ms)TCP up (Mbit/s)TCP down (Mbit/s)UDP @ 50M loss
chacha20-poly1305 / nonetunnel172.30.81.761.0 %
direct163.93.63.158.8 %

Here a single TCP flow collapses on both paths — classic high-RTT + 1 % loss behaviour for one cubic flow — with the tunnel a bit lower than direct because of the added latency. The UDP row shows the 50 Mbit offer saturating the 20 Mbit link (so ~60 % is shed), which the tunnel does at line rate just like the direct path: ShadowVPN itself is not the bottleneck on a lossy link, TCP's loss-recovery is.

Interpretation

  • Decoupling I/O from crypto roughly tripled single-flow TCP at high rates (≈0.3 → ≈1.0 Gbit/s) and closed the gap to the direct baseline at broadband rates. A dedicated reader task drains the socket/TUN continuously instead of stalling on the crypto+send of the previous packet, so the carrier stops dropping bursts and TCP can keep its window full.
  • Forwarding capacity is no longer the everyday limit. UDP runs at line rate with link-level loss in every realistic scenario; only a multi-Gbit one-way blast still saturates the single crypto+send processor.
  • The remaining TCP gap is latency- and loss-bound, not CPU-bound — widest on high-RTT/lossy links, where a single tunneled flow inherits the carrier's loss as inner-packet drops. The next lever for that regime is parallel crypto workers with a reorder buffer (a larger change, traded against packet reordering).
  • Cipher choice barely moves throughput here because the link, not the AEAD, is the bottleneck at ≤ 100 Mbit/s. On CPU-bound links (or ARM without AES acceleration) it matters more — see the cipher notes.

Scenario knobs

All environment variables, with defaults: CIPHER, OBFS (none/quic/base64), MTU, DELAY (one-way; RTT ≈ 2×), JITTER, LOSS, RATE, DURATION, UDP_RATE.

The harness needs NET_ADMIN and /dev/net/tun (requested by the compose file). It is a measurement tool, not a CI gate — the absolute numbers depend on the host.

Released under the MIT License.