SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

WireGuard slow speeds: find the real cause

Slow WireGuard is usually an MTU problem. Find your path MTU by bisection, clamp TCP MSS, check steal time, and measure the path before blaming the tunnel.

Where WireGuard slow speeds actually come from

WireGuard slow speeds trace back to one of four things, and those four are not equally likely. First is MTU (maximum transmission unit): the tunnel builds packets that are too large for one link on the path, so bulk transfers stall while small ones look healthy. Second is the path itself, which was already the limit before the tunnel existed. Third is CPU on a small shared VPS, where encryption competes with every other guest on the same host. Fourth is the peer's own connection.

Check them in that order. MTU comes first because it is the only cause on the list that WireGuard itself introduces, and because its symptoms do not look like slowness at all. A wrong MTU usually shows up as a tunnel that connects instantly, answers ping, accepts an SSH login, and then freezes the first time you copy a file.

One symptom is worth ruling out before any of this. If every new site takes several seconds to begin loading and then transfers at full speed, that is name resolution rather than throughput. DNS over WireGuard has its own failure modes, and no MTU change fixes them.

Why is the WireGuard MTU 1420?

Every packet you send into the tunnel is encrypted and wrapped inside a new packet. The wrapper costs bytes, and those bytes come out of your payload.

The WireGuard data header is 32 bytes: a 4 byte type field, a 4 byte receiver index, an 8 byte counter, and the 16 byte Poly1305 authentication tag. Around that sits a UDP header of 8 bytes. Around that sits the outer IP header, which is 20 bytes for IPv4 and 40 bytes for IPv6. Total encapsulation is therefore 60 bytes when your Endpoint is an IPv4 address and 80 bytes when it is IPv6. The WireGuard protocol page documents the message layout these numbers come from.

wg-quick does not guess at this. It reads the MTU of the interface that routes to your Endpoint, then subtracts 80. On an ordinary 1500 byte Ethernet path that produces 1420, which is the value ip link show wg0 prints. It subtracts 80 rather than 60 so the same figure stays safe if that endpoint is ever reached over IPv6, where the outer header is 20 bytes larger.

ChartMTU arithmetic for common underlays
The data behind this chart
[
  {
    "label": "Ethernet, IPv4 endpoint",
    "path_mtu": 1500,
    "encap_overhead": 60,
    "usable_wg0_mtu": 1440
  },
  {
    "label": "Ethernet, IPv6 endpoint",
    "path_mtu": 1500,
    "encap_overhead": 80,
    "usable_wg0_mtu": 1420
  },
  {
    "label": "PPPoE DSL, IPv4 endpoint",
    "path_mtu": 1492,
    "encap_overhead": 60,
    "usable_wg0_mtu": 1432
  },
  {
    "label": "Extra tunnel in the path",
    "path_mtu": 1400,
    "encap_overhead": 80,
    "usable_wg0_mtu": 1320
  }
]

Those 4 rows are arithmetic, not measurements. On a clean 1500 byte path with an IPv4 endpoint, 1440 would fit, so the default of 1420 leaves 20 bytes unused. That margin is intentional and it is not your problem.

The last row is the one that hurts. When some link on the path carries only 1400 bytes, a tunnel still set to 1420 produces a 1500 byte outer packet on every full size segment, which is 100 bytes more than that link will accept. The value that fits is 1320.

Do not copy 1320 either. Your path MTU is a property of your path, and the only way to know it is to measure it.

What a wrong MTU looks like

The failure is not gradual. It is a clean split between small packets and large ones.

  • ping across the tunnel works at any normal size.
  • An SSH login completes and typing feels responsive.
  • curl -I https://example.com returns headers immediately.
  • curl https://example.com on a large page hangs after the first few kilobytes.
  • scp of a big file starts and then stops at some percentage.
  • An SSH session freezes the moment you run a command that prints a lot of output.

All of this happens because a TCP connection only builds full size segments once it has bulk data to move. The handshake and the first request fit under every MTU on the path. The stall begins at the first full size segment, which is why the connection looks healthy right up to the point where it becomes useless.

An outer packet larger than the next link's MTU meets one of two fates.

It is fragmented. A router splits it and the far end reassembles the pieces. The transfer works and it is slower, because you now pay two packets for one and the receiver holds state until both arrive. Losing one fragment loses the whole original packet, so a path with 1% loss behaves like a much worse path. Many firewalls also drop IP fragments by policy, which turns this outcome into the next one.

It is dropped, and you may or may not be told. A router that will not fragment sends an ICMP (internet control message protocol) "fragmentation needed" message back to the sender, carrying the MTU it can accept. If that message arrives, path MTU discovery works and the sender lowers its segment size on its own. Many networks filter ICMP, so it often does not arrive. Nothing else reports the loss. That is the black hole: the packet leaves, nothing comes back, no error appears in any log at either end, and the transfer hangs until something times out.

How do I find the right MTU?

Measure the path, then do the subtraction. Test the underlay rather than the tunnel, so ping the server's public address from the client and forbid fragmentation.

ping -M do -s 1472 -c 3 203.0.113.10

-M do sets the DF (don't fragment) bit, so no router on the way is allowed to split the packet. -s is the ICMP payload size. A full IPv4 packet is that payload plus 8 bytes of ICMP header plus 20 bytes of IP header, so -s 1472 puts exactly 1500 bytes on the wire.

Three results matter. Clean replies mean 1500 bytes fits and MTU is not your problem. A local error means your own interface is already smaller than what you asked for:

ping: local error: message too long, mtu=1500

A reply from a router in the middle hands you the answer directly, and you can stop there:

From 203.0.113.1 icmp_seq=1 Frag needed and DF set (mtu = 1492)

100% packet loss at 1472, with clean replies at a smaller size, is the black hole case. No router is telling you anything, so find the edge by halving. Hold one size you know works and one you know fails, test the midpoint, and move whichever bound the result belongs to. Each round halves the remaining range, so five or six rounds is enough.

A worked bisection, one round at a time

Each line is a command you run on the client against the server's public address. The comment records what came back.

ping -M do -s 1472 -c 3 203.0.113.10   # 100% loss, so 1500 is too big
ping -M do -s 1272 -c 3 203.0.113.10   # replies, so 1300 fits
ping -M do -s 1372 -c 3 203.0.113.10   # replies, so 1400 fits
ping -M do -s 1422 -c 3 203.0.113.10   # 100% loss, so 1450 is too big
ping -M do -s 1397 -c 3 203.0.113.10   # 100% loss, so 1425 is too big
ping -M do -s 1384 -c 3 203.0.113.10   # 100% loss, so 1412 is too big

The largest payload that survived is 1372, so this path carries at least 1400 bytes and fewer than 1412. Take the safe end. A path MTU of 1400, minus 80 bytes of encapsulation, gives a wg0 MTU of 1320.

tracepath runs the same search on its own, and it is worth one run before you start bisecting:

tracepath -n 203.0.113.10

Its last line reports what it found:

     Resume: pmtu 1492 hops 12 back 12

Treat both tools as a starting point rather than proof. Some hosts rate limit or drop ICMP entirely, so a bisection can report a smaller MTU than the path really carries. The transfer that was failing is the real test.

Apply the value live first, because a wrong guess is then one command away from undone:

sudo ip link set mtu 1320 dev wg0

Retry the transfer that was hanging. If it completes, make the value permanent in the client's [Interface] block:

[Interface]
PrivateKey = <client private key>
Address = 10.8.0.2/32
MTU = 1320
sudo wg-quick down wg0 && sudo wg-quick up wg0
ip link show wg0

ip link show wg0 should now print mtu 1320. If it still prints the old value, wg-quick did not read the file you edited. Check that you edited /etc/wireguard/wg0.conf and that MTU sits under [Interface] and not under [Peer], where it is ignored.

MTU is a property of one interface and it is never negotiated between peers. Setting it on the client only shrinks the packets the client sends. The server keeps building packets at its own wg0 MTU, so downloads can still black-hole after uploads start working. Set the value at both ends, or clamp MSS on the server.

Why MSS clamping fixes TCP and nothing else

If the server forwards traffic for its peers, which it does in any standard WireGuard VPS setup using NAT (network address translation), one rule fixes TCP for every peer and saves you from chasing a value on each client you do not control.

MSS (maximum segment size) is a TCP option each side puts in its SYN packet to state how large a segment it is willing to receive. Clamping rewrites that option in flight to match the real route MTU, so both ends agree on a smaller segment before any data moves. It works because it happens during the handshake, and because it does not depend on an ICMP message the path is probably filtering.

With nftables, add this table to /etc/nftables.conf below your existing tables:

table inet mangle {
  chain forward {
    type filter hook forward priority mangle; policy accept;
    tcp flags syn tcp option maxseg size set rt mtu
  }
}

Reload with sudo systemctl reload nftables. On an iptables box the equivalent is one line:

sudo iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

Confirm the rule is on the path the packets take. Run sudo nft list table inet mangle or sudo iptables -t mangle -L FORWARD -n -v while a client opens new connections, and watch the counter rise. A counter stuck at zero means the packets are not passing through that hook, so the rule is doing nothing.

Now the honest limits. Clamping covers TCP and nothing else, and it covers forwarded traffic only, so a service running on the WireGuard server itself never crosses the forward hook and is never clamped. It also affects connections opened after the rule loads: existing sessions keep the MSS they already agreed on.

UDP is untouched, because UDP has no handshake to rewrite. Most UDP traffic survives anyway. QUIC, the transport under HTTP/3, probes its own usable packet size and starts small on purpose. What does not survive is UDP that sends one large datagram and expects it through, such as a DNSSEC (DNS security extensions) answer over 1400 bytes. Those queries time out and retry over TCP, which reaches the user as a slow site rather than a broken one.

Is my VPS CPU the limit?

WireGuard encrypts with ChaCha20-Poly1305 and exchanges keys with Curve25519. There is no AES anywhere in the data path, and that has one consequence people get wrong: the AES-NI instructions in your CPU do nothing for WireGuard. A host advertising AES-NI is not offering you a WireGuard performance feature. ChaCha20 was chosen because it is fast in plain software, including on CPUs with no crypto acceleration at all.

That does not make WireGuard free. On a 1 vCPU VPS, one core handles both the encryption and the network interrupts, on top of whatever your application is doing.

Measure it while a transfer runs:

sudo apt install -y sysstat
mpstat -P ALL 1

Read three columns. %soft is softirq time, which is where kernel packet processing lands. %steal is time the hypervisor handed to somebody else. %idle is what is left over.

%soft near 100 on your only core means the box has hit its packet processing ceiling, and that is a real limit that more cores will raise. top shows ksoftirqd/0 at the top of the process list at the same moment, which is the same finding from a different angle.

%steal above a few percent means the limit is not yours to fix, because the host is oversubscribed and your vCPU is waiting for a physical core. This is common on the cheapest shared plans and it moves through the day. Steal time from a noisy neighbour needs its own investigation, and no MTU value will help with it.

One more factor is which implementation the client runs. The Linux kernel module is the fast path and it spreads a peer's encryption across cores. wireguard-go, the userspace implementation, is slower, and it is what macOS and iOS clients use, because those platforms do not let an app load a kernel module.

Before tuning anything, take two numbers from the same client minutes apart: throughput without the tunnel, and throughput with it. Without that pair you are guessing.

Run iperf3 -s on the server. The direct test needs TCP 5201 reachable on the public address, so open it for the duration and remove the rule afterwards. Confirm the port is closed again rather than assuming it.

# on the client, outside the tunnel
iperf3 -c 203.0.113.10
# then through the tunnel
iperf3 -c 10.8.0.1

If the two numbers are close, WireGuard is costing you very little and the path is the limit. If the tunnel number sits far below the direct number while %soft stayed low, return to MTU. Fragmentation reduces throughput without breaking anything, so it appears here as a percentage loss rather than a hang.

Test both directions, because home connections are usually asymmetric. iperf3 -c 10.8.0.1 -R reverses the flow so the server sends. A client on a 500/20 line will never push more than its 20 Mbit upload into the tunnel, and no server-side change alters that.

Then test with parallel streams:

iperf3 -c 10.8.0.1 -P 4

If four streams together move much more than one, a single TCP connection is failing to fill the path. Throughput of one stream is bounded by the receive window divided by the round trip time, so a 150 ms path needs a large window to carry much data. Read your own limits:

sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem

The third value in each is the maximum Linux will autotune to. Packet loss caps a single stream hard as well, because loss is what TCP congestion control reacts to, and a long path makes recovery expensive. Run mtr -rwc 100 203.0.113.10 from the client for a hundred cycles to see where loss appears along the route. Loss that starts at one hop and continues to the final hop is real. Loss at one middle hop that disappears after it is that router deprioritising ICMP, and it means nothing.

For a repeatable picture of the server itself, separate from the network, benchmark the VPS with a documented method so you can run the same test again after a change and compare like with like.

What WireGuard cannot fix

WireGuard is a tunnel. It cannot be faster than the slowest link on the path it uses, and adding it always makes a path slightly slower.

It does not compress. There is no equivalent of OpenVPN's comp-lzo and no plan for one, because compressing before encrypting leaks information about the plaintext. Most bulk data is already compressed, so this costs you nothing in practice. It is one of the real differences to weigh when you compare WireGuard against OpenVPN, and it is a deliberate design choice.

A full tunnel changes the route every packet takes. Traffic that used to run from you to a nearby CDN (content delivery network) now runs from you to your VPS and then to the CDN. If the VPS is on another continent, every request pays that detour and the round trip time grows accordingly. No config value shortens it. Move the VPS closer, or use a split tunnel so only the traffic that needs the VPN takes the long way. Which traffic goes where is decided entirely by AllowedIPs, and cryptokey routing explains how that decision is made.

Provider limits sit outside the tunnel and they are easy to forget. A plan with a monthly bandwidth allowance often shapes the port to a far lower speed once the allowance runs out, and the tunnel then looks broken. Check your panel before you spend an evening on MTU.

PersistentKeepalive does not affect throughput. It exists to hold a NAT mapping open so the server can still reach a client behind a home router. Lowering it below 25 seconds adds packets and fixes nothing.

Measure in this order

  1. Reproduce the problem and note whether it is a hang or an even slowdown. A hang points at MTU. An even slowdown does not.
  2. Bisect with ping -M do from the client to the server's public address, and write down the path MTU.
  3. Subtract 80, set that MTU on wg0 at both ends, and retest the transfer that was failing.
  4. Add MSS clamping on the server if it forwards traffic for peers.
  5. Run mpstat -P ALL 1 during a transfer and read %soft and %steal.
  6. Run iperf3 outside the tunnel and inside it, in both directions, single stream and with -P 4.
  7. Run mtr -rwc 100 to the server and look for loss that persists to the last hop.

Change the server plan only after step 5 says the CPU is the ceiling. Steps 1 through 4 cost nothing and resolve most reports of a slow tunnel.

FAQ

Why is my WireGuard tunnel fast for ping but slow for downloads?

That split is the signature of an MTU problem. Small packets fit under every link on the path, so ping and an SSH login work. A bulk transfer sends full size segments, the encapsulated version of those is larger than some link accepts, and if that router drops them without an ICMP message getting back, nothing reports the loss and the transfer hangs. Find the path MTU with a ping -M do bisection to the server's public address, subtract 80 bytes for the encapsulation, and set the result as the wg0 MTU at both ends.

What MTU should I set for WireGuard?

There is no universal number, which is exactly why the default of 1420 fails for some people. 1420 is 1500 minus 80 bytes of WireGuard header, UDP header and outer IPv6 header. If your path carries fewer than 1500 bytes, which is normal on PPPoE DSL and anywhere your traffic crosses another tunnel, you need a smaller value. Measure your path MTU first, then subtract 80 from it.

Does MSS clamping replace setting the MTU?

No. Clamping rewrites the MSS option in the TCP handshake so both ends send smaller segments, which fixes TCP without touching the interface. UDP has no handshake to rewrite, so it is unaffected. Clamping also applies only to traffic the server forwards, so a service running on the WireGuard server itself does not benefit. Use both: a correct MTU on the interface, and clamping to catch peers whose configuration you do not control.

Will a faster VPS plan make WireGuard faster?

Only if the CPU is the limit, and one command tells you. Run mpstat -P ALL 1 while a transfer is running. %soft near 100 on your only core means packet processing is the ceiling and more cores will raise it. A high %steal means the host is oversubscribed, so a different plan or a different host is the answer. If both numbers are low while the tunnel is still slow, the CPU is idle and a bigger plan changes nothing.

Why is my Mac slower than my Linux client on the same network?

The Linux client uses the in-kernel WireGuard module, which processes packets in kernel space and spreads a peer's encryption across CPU cores. The macOS and iOS apps use wireguard-go, a userspace implementation, because those platforms do not let an app load a kernel module. Userspace copies each packet between the kernel and the application, and that copying costs throughput. The gap is expected, and no client setting closes it.