Latency under load on Windows
2026.09
Most "network optimizer" software writes a handful of registry keys, shows a progress bar, and reports a percentage it never measured. accel inverts the order. The measurement harness was built first, and no tweak ships until an A/B run produces a confidence interval for it on a real link.
Throughput is rarely the constraint on a modern connection. Latency under load, jitter and loss are what decide whether a game feels good, so those are the targets.
What got ruled out before anything was built
BBR was the obvious first idea and the first thing cut. Congestion control is a sender-side algorithm, so running BBR locally governs uploads only; for the traffic that matters on a download, the remote server is the sender. Proton's VPN Accelerator gets the benefit because their server terminates your connection and becomes that sender. A local-only tool sits on the wrong side of the pipe. Windows also exposes no pluggable congestion control framework, so there is no BBR to load in the first place.
The well-known registry tweaks went the same way, for a duller reason: run them through the A/B harness and most of them measure as no change. They are reported as no change.
The instrument comes first
Windows parks a thread on a blocking connect() and wakes it on a scheduler tick, which adds roughly 15.6 ms to an unpredictable fraction of samples. Measured against a loopback target whose true RTT is ~0.08 ms:
| prober | p50 | p99 | jitter |
|---|---|---|---|
blocking connect() | 9.75 ms | 10.90 ms | 4.66 ms |
| non-blocking + spin | 0.088 ms | 0.164 ms | 0.014 ms |
Against 1.1.1.1 the blocking prober reported 3.2 ms of jitter that does not exist. Tuning against that number would have produced convincing graphs and zero improvement.
So the prober uses a non-blocking connect, spins on select(), runs at time-critical thread priority, and multiplexes every target onto one thread to keep the spin cost bounded. Every run also probes a loopback control target to establish an instrument noise floor, and any measured difference below that floor is reported as not credible.
Percentiles get the same treatment. A p99 drawn from 125 samples is the maximum wearing a costume, so every reported percentile carries a bootstrap confidence interval, and one computed from too few samples is flagged rather than presented as fact.
Two tails that look identical
A bad p99 has at least two causes that want opposite fixes, so accel classifies the shape of the tail rather than reporting its size. Queue buildup spreads latency continuously as the queue fills and drains. Loss plus retransmit produces a discrete cluster at base + k x minRTO with an empty gap beneath it, because the SYN is simply resent after the retransmission timeout.
google download: 430 samples @ 25-50ms ... [EMPTY 75-325ms] ... 17 samples @ 300-350ms
LOSS-RETRANSMIT 3.4% of samples at base+1xminRTO with a 245ms empty gap
-> packet loss on the path, egress shaping will not help
Shaping that link would have produced a graph, a changelog entry, and no improvement.
The shaper
Windows has no fq_codel, no CAKE, and no working AQM at all. That gap is the one thing here no registry key can substitute for, so shaper/ is an egress fq_codel implementation in Rust riding WinDivert, filtered to the interface holding the default route so WSL and Hyper-V vSwitch traffic is left alone. Rust because a GC pause inside a component whose entire job is to avoid adding jitter would be self-defeating.
One accel measure with the shaper off, one with it on at 320 Mbps on a ~347 Mbps link, bootstrap CIs over the pooled samples, instrument noise floor 0.748 ms:
| target | phase | before | after | delta | 95% CI |
|---|---|---|---|---|---|
| cloudflare | upload p50 | 25.17 | 7.05 | -18.12 ms | -18.53 .. -17.88 |
| cloudflare | upload p99 | 35.97 | 21.51 | -14.46 ms | -50.84 .. -2.65 |
| cloudflare | bidir p50 | 29.77 | 27.78 | -1.99 ms | -2.67 .. -1.45 |
| upload p50 | 34.31 | 16.48 | -17.84 ms | -18.10 .. -17.49 | |
| upload p99 | 50.92 | 24.47 | -26.45 ms | -76.91 .. -8.40 | |
| bidir p50 | 38.19 | 36.91 | -1.28 ms | -1.79 .. -0.81 |
Roughly 18 ms off the median upload latency on every remote target, landing within a millisecond of the idle figure. Loss during the bidirectional phase fell too, cloudflare 7.0% to 2.9%.
The cost:
| before | after | ||
|---|---|---|---|
| download throughput | 331.9 Mbps | 323.2 Mbps | -2.6% |
| upload throughput | 336.6 Mbps | 308.4 Mbps | -8.4% |
| bidir upload | 296.0 Mbps | 281.3 Mbps | -5.0% |
| idle latency, all targets | +0.26 to +0.36 ms |
Pacing at 320 on a 347 Mbps link is the mechanism, so the upload cost is the price of admission. The idle penalty sits below the noise floor for any single target, but it appeared on all three independently, which is corroboration a single-target test cannot give. Call it a third of a millisecond, always, in exchange for eighteen when the link is busy.
One result in that run has to be thrown out. The google download p99 moved from +323.72 ms to +26.58 ms and the comparison flags it as significant. The bidirectional phase in the same run reads 340.81 ms before and 339.37 ms after, unchanged, which means the tail is still there and the download phase missed it that time. The classifier had already identified it as loss-retransmit, which egress shaping cannot touch. Without that classifier this section would be claiming a 297 ms improvement.
Pure ACKs skip the queue
A saturating download turns your machine into a sender of acknowledgements. A measured run showed 22,000 packets/sec outbound at 62 bytes each, with the queue reading 0 bytes and the AQM never firing. Diverting that through userspace added a round trip to every segment being acknowledged and bought nothing.
Pure ACKs (TCP, no payload, no SYN/FIN/RST) are now reinjected immediately. They occupy no sequence space, so jumping the queue cannot reorder anything that matters, and their bytes are still charged against the pacer so the shaped rate stays honest. Confirmed live during a saturating download: 17,622 pps outbound, 17,593/s bypassed, queue flat at 0 bytes. --shape-acks puts them back if you want to measure the difference yourself.
A bounded priority tier
fq_codel already keeps a sparse flow off the back of a bulk queue, but DRR still costs a marked flow one quantum per other active flow. Twenty flows at 1500 B on a 20 Mbps uplink is 12 ms.
shaper.exe --rate-mbps 320 --game-proc mygame.exe
shaper.exe --rate-mbps 320 --game-port 27015
--game-proc maps processes to ports through WinDivert's FLOW layer, seeded at startup from the live connection tables so a game already running still matches. No list of game executables ships with the tool; --flows prints what to type.
The priority is bounded on purpose. A marked flow keeps the fast lane only while its backlog stays under --prio-kb (default 6 KB), then gets demoted to normal service, because a flow that can hold a backlog has stopped being sparse. A second guard yields to normal traffic after four consecutive packets regardless. Marking a bulk transfer by mistake therefore costs the flow its priority and nothing else. Demotion is deliberately non-sticky, since games routinely pull assets in bulk and then settle into a steady low-rate stream. Against a deliberately mismarked bulk uploader: 1,240 demotions with outbound throughput holding at exactly 320.0 Mbps.
Sizing the buffer
An early run caught the queue cap doing the AQM's job: 779 overflow drops against 384 AQM drops. A tail drop is blind where CoDel is deliberate, so that ratio is backwards. The cause was a flat 1 MB cap, which is about 680 packets shared across every active flow. The default is now 200 ms of the shaped rate with a 4 MB floor, which at 320 Mbps is 7812 KB. Re-measured under the same load: overflow 0, AQM 26, queue peaking at 306 KB.
Passive RTT from the datapath
Every round trip on the link is already being measured by TCP, and the shaper reads the same signal: note when an outbound segment carrying data goes out, match the acknowledgement covering it, subtract.
rtt min 16.84ms | now 32.58ms | +12.18ms queueing | 35 flows, 398 samples
Three details separate this from a naive version. It follows Karn's algorithm, so a retransmitted segment is never timed, because an acknowledgement for it cannot be attributed to either transmission. It resets on SYN, because a reused local port otherwise lets a fresh acknowledgement match a stale entry through the wrapped sequence comparison; that bug produced sub-100-microsecond "internet RTTs" in testing. And it reports the median of per-flow minimums, so one sub-millisecond LAN neighbour does not become the path RTT for everything.
The inflation figure is taken only from sparse flows. A bulk flow sits in whichever queue exists and is inflated whether that queue is ours or the modem's; measured live, bulk-flow inflation read +12.18 ms while shaping correctly and +5.67 ms while overshooting at 900 Mbps, inverted and useless for deciding anything. Sparse flows separate the two cases. When we hold the queue, sparse flows are served ahead of the bulk; once the queue moves downstream into a FIFO, nothing protects them. On the same link that reads ~0.2 ms in control and ~15 ms out of it.
Only flows that send data produce samples, so a pure download yields none. That covers uploads and request/response traffic, which is to say games. Bulk inbound data is excluded from the sniff by payload length, so watching a 350 Mbps download costs a few hundred packets per second rather than thirty thousand.
Finding the rate by itself
--rate-mbps asks you to know your uplink and to re-measure it whenever it changes. --auto works it out from one observable: pacing below the true bottleneck makes traffic back up locally where CoDel can see it, and pacing above it lets the local queue drain while the backlog forms in the modem. A standing local queue means there is headroom left to claim.
rate 10.0 -> 15.6 -> 24.4 -> 38.1 -> 59.6 -> 93.1 -> 145.5 -> 227.4 -> 355.3
then hunting: 327.8 360.6 376.8 393.8 374.1 337.6 352.8 335.2
About four seconds to find the ceiling on a link whose true capacity is ~347, then a tight hunt around it with throughput holding at 340-350 Mbps and zero overflow drops.
Backlog alone only reveals when we are the bottleneck. Passive RTT closes the other half: if sparse flows see inflated round trips while the local queue is empty, the queue is downstream, and the only way it got there is us feeding the bottleneck faster than it drains. Delivered throughput at that moment is the bottleneck's capacity, so the correction is a single jump. Started deliberately at 900 Mbps on a ~347 Mbps link, a case that was previously unrecoverable:
rate 900.0 | sparse + 1.56ms | climbing <- no evidence of a queue anywhere
rate 900.0 | sparse +10.68ms | has backed off <- trigger; one jump to delivered x 0.95
rate 233.5 -> 242.0 -> 292.9 -> 322.2 <- back in control, climbing again
rate 323.2 | sparse + 4.82ms | settled
Only blame yourself for a queue you could have built
"Delivered throughput is the bottleneck's capacity" holds only while you are saturating the link. On an idle machine delivered throughput is demand, and delivered x 0.95 becomes a ratchet toward zero: each adjustment lowers the rate, and climbing back needs load an idle machine never offers.
That happened on a second machine. A 338 Mbps link carrying 0.3 Mbps of real traffic reported ~47 ms of sparse inflation, and the rate walked down to 2.4 Mbps over 34 adjustments, dropping packets to enforce a limit it had invented. A 140x undershoot.
The fix is a physical plausibility check rather than a load threshold. A queue draining at delivered_bps that adds inflation_us of delay holds delivered/8 * inflation bytes; at 0.3 Mbps and 47 ms that is 1.7 KB, one packet. One packet in flight cannot cause 47 ms of queueing, so the delay belongs to someone else and backing off cannot help. Inflation must also come from at least three sparse flows, since the figure is a median and across two flows a single badly behaved peer sets the whole reading. Live it swung 47 / 6 / 6 / 37 ms on consecutive ticks.
The asymmetry is deliberate. Failing to back off costs the shaping benefit; backing off wrongly destroys the link. The gates refuse a correction when unsure.
Inbound: receive-window clamping
Egress shaping cannot touch download bufferbloat, since we are not the sender. We are the receiver, though, and a receiver controls how much the far end may keep in flight. Cap the advertised window and you cap the queue that can form at the bottleneck.
shaper.exe --rate-mbps 320 --down-mbps 500 --rtt-ms 50
The cap is the bandwidth-delay product, down_mbps x rtt_ms. Measured with a deliberately tight 48 KB cap: 21,277 packets rewritten, and three separate 20 MB downloads came back byte-identical. Byte-equality is the correctness check; a mis-patched checksum surfaces as stalls and resets, not as an error.
Set --rtt-ms to your real RTT. The ceiling this imposes is window / actual RTT, with no dependence on --down-mbps alone. In that test a 48 KB window over a genuine 8 ms path predicted 6.14 MB/s and delivered 5.84-6.09. Set the RTT too low and you throttle yourself, precisely and quietly.
Two guards make it shippable. The window is never taken below four segments and never to zero, since a zero window is a stall the sender has to be explicitly released from. And the scale shift is applied only once the inbound SYN-ACK confirms the peer negotiated window scaling; guessing wrong there divides every window by up to 16384, so connections whose handshake we never saw are left alone.
DNS is measured, never intercepted
An early plan had a DNS layer: local caching, racing resolvers, prefetching. It was cut for the same reason the registry tweaks were.
The honest result for a DNS layer is "saved 30 ms, once, at startup", and there is no way to put that through accel ab and get a number worth printing. Beyond the evidence bar, your resolver configuration is deliberate. Pi-hole, NextDNS, AdGuard, corporate resolvers, split-horizon rules for a WireGuard tunnel: an interception layer breaks all of those silently, by leaking internal names to a public resolver it raced, or by caching split-horizon answers across tunnel transitions.
So accel measure times DNS through getaddrinfo, the same path every application takes, and changes nothing. The uncached probe is a random label under a real domain, so it is a full recursive round trip with nothing for a cache to short circuit.
| idle (median) | under load (median) | |
|---|---|---|
| shaper off, uncached | 28.36 ms | 52.99 ms |
| shaper on, uncached | 30.36 ms | 46.92 ms |
| either, cached | 0.32 ms | 0.33 ms |
Saturating the link roughly doubles the cost of resolving a new name, and cached lookups are untouched, which follows from their never leaving the machine. The shaper does not demonstrably fix it: the median moved 52.99 to 46.92 ms, the maximum went the other way (66.72 to 89.64 ms), and n=15 per phase is nowhere near enough to call either. Unresolved, rather than a win.
The likely explanation is that this queue is out of reach. Queries go to the router, which does the recursion, and the response comes back down a path we have no control over. Receive-window clamping is the only lever here that might apply, and it has not been tested against this. It still does not argue for building the DNS layer: a local cache cannot help a name that has never been looked up, and uncached queries are exactly what got slower.
Grading
Latency under load, measured as loaded p99 minus idle p50:
| grade | increase |
|---|---|
| A+ | < 5 ms |
| A | < 30 ms |
| B | < 60 ms |
| C | < 200 ms |
| D | < 400 ms |
| F | >= 400 ms |
What is still open
The handshake probe cannot see Nagle or delayed-ACK effects, so a payload-echo probe is still outstanding. --auto learns nothing from an idle link, since with no traffic offered there is no way to tell a fast link from a slow one. And inflation caused by a different device saturating the same line is indistinguishable from our own overshoot, so the shaper backs off for it. Sending harder cannot drain someone else's queue, so that behaviour is defensible, but a bloated neighbour does cap this machine's rate.
Measurement needs no elevation. The shaper does, and its failure mode is fail-open: if the process dies the WinDivert handle closes and traffic returns to normal.
MIT licensed.