# Your Hyper-V checkbox turned off a UDP fast path

> Windows has a software UDP receive offload that QUIC stacks ask for. Enabling WSL, Docker or Hyper-V disables it system-wide, and no API says a word.

- Author: Simon Wimmesberger
- Published: 2026-08-21
- Canonical: https://blog.wimmesberger.dev/posts/udp-ladder-hyperv-uro/
- Tags: networking, performance, windows

---
*A chapter of [The UDP performance ladder](../udp-performance-ladder/), and the one that generalizes furthest beyond it. Nothing here needs the rest of the series: if you run QUIC, HTTP/3 or any UDP-heavy service on Windows, this is about a fast path your machine may have switched off years ago without telling you.*

Windows can coalesce inbound UDP. The stack merges several datagrams from one flow into a single buffer and hands them over in one receive, so a busy server pays one syscall and one trip up the stack for a batch instead of for every packet. Microsoft calls it UDP Receive Segment Coalescing Offload, URO, and it is the receive-side twin of the segmentation offload that QUIC implementations already ship on. A socket opts in with one `setsockopt`:

```csharp title="opting into URO"
socket.SetSocketOption(SocketOptionLevel.Udp,
    (SocketOptionName)UDP_RECV_MAX_COALESCED_SIZE, 65527);
```

[msquic](https://github.com/microsoft/msquic) does exactly this at startup, which means IIS, .NET's HTTP/3 client and every Windows service built on msquic want URO. On the machine I benchmark with, they never got it, and finding out why took a kernel trace and an operating-system feature uninstall.

## Every API said it was fine

The failure has no symptom. The `setsockopt` returns zero. `netsh int udp show global` reports `Receive Offload State: enabled`. A socket that has opted in receives normally, forever, one datagram at a time. There is no error, no event, no counter.

The obvious check makes it worse, because it answers a different question:

```powershell frame="terminal"
Get-NetAdapterUro -Name 'Ethernet 9'
# (nothing)
```

That cmdlet reports the *hardware* offload, the NIC's ability to coalesce on its own. My Realtek has none, and I spent a while believing that settled it. It does not: URO also exists in software, inside tcpip.sys, and a NIC that cannot do it in hardware can still benefit. Proof came later, when the same adapter started coalescing 28 datagrams into a single receive while that cmdlet still reported nothing.

The only reliable test is behavioral: opt in, receive real traffic, and count how many datagrams arrive per receive. Two traps make that harder than it sounds. **Loopback cannot test URO at all**, because delivery there is synchronous per send and no batch ever exists to merge; I measured zero coalescing over loopback on a machine where the wire coalesced heavily. And **URO ignores small datagrams**: at 32 bytes it never engages, at 64 and above it does, so a probe using tiny packets proves nothing either.

With both traps avoided, the strongest probe I could construct still came back empty: a single flow of QUIC-sized 1200 byte datagrams (URO's design target) over the real wire, deliberately allowed to pile up in a backlogged four-megabyte socket queue, then drained through the exact receive shape msquic uses (raw `WSARecvMsg` with control-buffer space for the coalescing metadata, msquic's own maximum-coalesced-size value). Two hundred ninety thousand receives, every one exactly 1200 bytes, zero coalescing cmsgs. The obvious suspect after that was a driver: Windows disables coalescing when an NDIS filter of the wrong vintage binds to the interface, and [Npcap](https://github.com/nmap/npcap/issues/737), the capture driver that ships with Wireshark, documents in its own issue tracker that installing it does exactly that. It was bound to every adapter on this machine. So I unbound it: no change. Then I stripped the adapter to bare IPv4, disabling the VMware bridge, a vendor NDIS protocol driver, the layer-2 bridge, QoS, IPv6, file sharing, all of it: still not one coalesced datagram. At that point user-space guessing was exhausted.

## The two lines that explain it

msquic's [troubleshooting guide](https://microsoft.github.io/msquic/msquicdocs/docs/TroubleshootingGuide.html) is the only documentation I found that treats software URO as something you might have to debug, and it names the trace event that proves URO alive: `URO SCU received. SegCount = 5, SegSize = 1000, DataLength = 5000`. So collect a verbose TCPIP trace, generate traffic, and search it:

```powershell frame="terminal"
netsh trace start capture=no report=no overwrite=yes traceFile=uro.etl `
    provider=Microsoft-Windows-TCPIP level=5 keywords=0xffffffffffffffff
# ... run your traffic ...
netsh trace stop
netsh trace convert input=uro.etl output=uro.txt
```

Mine had no `URO SCU` events at all, but it had these:

```txt wrap title="Microsoft-Windows-TCPIP, verbose"
Framing: interface rundown: Interface = 5, ... Alias = Ethernet 9,
    SW RSC/URO applicable = 1(TRUE), SW RSC enabled = 1(TRUE), SW URO enabled = 1(TRUE).

TCP software RSC global disabled mask = 0, UDP software URO global disabled mask = 48.
```

The interface was willing: software URO applicable, software URO enabled. What stopped it was that **global disable mask**, and the guide decodes the values. Zero means healthy. **2** means an incompatible WFP callout is registered. **48** means an incompatible IPSNPI client, which the guide names: `winnat` or FSE, both of which *"can automatically get enabled when WSL or Hyper-V are enabled on a machine."*

That is the whole answer. Not the NIC, not the driver, not the capture filters I had suspected and unbound one by one. A virtualization feature, enabled once, disables an unrelated networking optimization for every socket on the machine, permanently and silently. Both culprits are present here: the `winnat` service is running and a Hyper-V container adapter is bound, because this workstation hosts the WSL2 virtual machine that runs the Linux half of [the ladder's](../udp-performance-ladder/) measurements. Read that back slowly, because it is the best joke this project told me: **the Linux half of the benchmark switched off the Windows feature the Windows half was trying to measure.** Not by competing for CPU, not by stealing the NIC, but by existing, enabled long before this project, as a checkbox. And TCP's equivalent feature keeps working in the same trace, with its own mask at zero: one protocol vetoed, one not, no API anywhere that will tell you which.

Stacked up, the failure looks like this: every layer you can ask says yes, and the one layer that says no is invisible from user space.

## Confirming it costs a reboot

Stopping `winnat` and disabling the Hyper-V virtual adapters does nothing: those clients register with the stack at boot, so the mask stays 48. The only thing that clears it is removing the features and restarting:

```powershell frame="terminal"
Disable-WindowsOptionalFeature -Online -FeatureName `
    Microsoft-Hyper-V-All, VirtualMachinePlatform, Microsoft-Windows-Subsystem-Linux
# reboot
```

After the restart, the mask read 0 and the same traffic behaved completely differently. Identical bytes over the same wire, before and after:

| | Receives | Datagrams per receive | Coalescing metadata |
| --- | --- | --- | --- |
| Mask 48 (WSL and Hyper-V present) | 300,000 | 1 | none |
| Mask 0 (features removed) | **46,905** | up to **28** | 45,259 cmsgs |

**An 84% reduction in receive syscalls**, with 253,095 datagrams delivered as passengers inside other receives, on the NIC whose hardware URO is nonexistent. The software path had been there the whole time.

## What it is worth

Syscall counts are not CPU, so I measured that too, with the forwarder from the rest of this series: 1200-byte datagrams (the size QUIC actually sends), 80,000 packets per second, five rounds alternating between the two configurations so drift lands on both.

| | Round 1 | 2 | 3 | 4 | 5 | Mean |
| --- | --- | --- | --- | --- | --- | --- |
| Send offload only | 14.0% | 14.0% | 10.9% | 14.0% | 15.6% | 13.7% |
| Send offload + URO | 10.9% | 10.9% | 10.9% | 12.5% | 12.5% | **11.5%** |

**URO is worth about 2.2 points of a core here, a sixth of the forwarder's CPU, and it never once cost anything.** Every round favors it or ties. That is a receive path doing a sixth less work for a `setsockopt` you have already written, on hardware that officially cannot do this.

Two honest limits on that number. It is a UDP forwarder, not a QUIC server: a real HTTP/3 stack also spends cycles on encryption and congestion control, so the same absolute saving is a smaller fraction of its total. And the gigabit link holds this test to 80,000 packets per second; on faster links the receive path is a larger share of the budget, which should make URO matter more, not less.

## So who is running without it?

Every mechanism that sets mask 48 is something people enable for reasons unrelated to networking, usually once, usually years ago:

- **WSL 2**, on essentially every Windows developer machine that touches Linux.
- **Docker Desktop**, which requires WSL 2 or Hyper-V.
- **Hyper-V** itself, including a single VM created and forgotten.
- **Windows Sandbox** and other features that pull in the same platform.

One obvious suspect is innocent, which is worth stating because it bounds the damage. Virtualization-Based Security runs its own hypervisor, so I expected it to poison URO too. It does not: on this machine, after the features came off, `Win32_DeviceGuard` reports VBS running and `HypervisorPresent` is still true, and URO coalesces anyway. A hypervisor being present is not the problem. The problem is the NAT and container plumbing that the WSL, Hyper-V and containers *features* register with the network stack, which is narrower and more actionable: this is about what you installed, not about how your machine boots.

None of this means Hyper-V is a mistake. It means a virtualization checkbox has an undocumented networking cost that nobody, including the QUIC stack asking for the feature, will report to you.

## Checking your own machine

The tooling from this series is in the [repository](https://github.com/swimmesberger/udp-performance-ladder), under `bench/`:

1. `fix-uro.ps1 -Diagnose` lists bindings, the IPSNPI clients and the global offload switch.
2. `uro-trace.ps1` (elevated) collects the trace and greps for the two lines that matter.
3. Read the mask. **0** and you are fine. **2** means a WFP callout. **48** means the virtualization stack.
4. `fix-uro.ps1 -Bisect` (only useful once the mask is 0) enables each suspect NDIS filter in turn against live traffic and reports which actually break coalescing. On my machine, none did, Npcap included: its [documented problem](https://github.com/nmap/npcap/issues/737) did not reproduce here, because current builds negotiate their NDIS version at runtime (the issue stays open for full support of the newest NDIS).

If your mask is 48 and you need the receive path more than you need Hyper-V, the fix is the uninstall and a reboot. If you need both, at least now you know what you are paying, which is more than the operating system will tell you.

**The rule this leaves me with: on Windows, never assume an offload is running because the API accepted it.** Coalescing, like every other silent optimization in this series, has to be observed to be believed, and the only place it can be observed is a kernel trace.
