DEV Community

Cover image for Parallel connections made my speed test 2x slower
Hammad Shams Uddin
Hammad Shams Uddin

Posted on • Originally published at buildingutilorax.hashnode.dev

Parallel connections made my speed test 2x slower

I built a speed test last week. It told me my download was 8.2 Mbps. Speedtest.net, on the same line, five minutes later, said 28.65.

My first instinct was that this was a distance problem, and I had a good reason to think so. Speedtest.net had picked a PTCL server in Karachi — same city, 3 ms away. Mine measures to whichever Cloudflare edge answers, and from here that is Singapore, 120 ms away. Of course a server on another continent reads lower.

That explanation was comfortable, it fit the numbers, and it was wrong.

The measurement that killed it

Before rewriting anything, I checked the assumption with curl. Same machine, same file, same Cloudflare edge, all within a minute of each other:

how the file was fetched throughput
one connection at a time, sequentially 19.2 Mbps
six parallel connections 11.1 Mbps
my speed test, eight HTTP/2 streams 8.2 Mbps

The path was capable of 19 Mbps. My tool was reporting 8. And the more connections I opened, the worse it got — the exact opposite of what I had built the thing to assume.

Why I had it backwards

Every guide on measuring bandwidth says the same thing, and it is not wrong: a single TCP connection often cannot fill a long fat pipe. Throughput per connection is bounded by the congestion window divided by the round trip time, so on a 120 ms path one connection tops out well below the line rate no matter how fast the line is. Open several and you fill the pipe.

I had believed this hard enough that, an hour before running that curl test, I had tuned the default from six streams to eight — reasoning that a long path needs more parallelism. I made it worse and felt clever doing it.

What that advice omits is that it assumes spare capacity. Parallel connections do not politely share a fixed pot of bandwidth; each one runs its own congestion control and pushes until it sees loss. On an uncongested path they ramp independently and add up. On a path that is already the bottleneck — an oversubscribed international link, in my case — they push it into loss together, every one of them backs off, and the aggregate collapses below what a single well-behaved connection was getting.

Both behaviours are real. Which one you get depends on the path, and you cannot know which from the client.

So stop guessing and measure it

The fix was to stop picking a number. The download phase now runs twice — once with two connections, once with eight — and reports whichever went faster, showing both.

On my line, live:

2 connections : 15.1 Mbps
8 connections :  5.18 Mbps
Enter fullscreen mode Exit fullscreen mode

Same file, same run, ninety seconds apart. Three times the difference, purely from concurrency.

The reported download went from 8.2 to 15.1 Mbps. Still below Ookla's 28, and I will come back to why, but it is now measuring the connection rather than measuring my assumption about the connection.

Two ways I rigged my own experiment

The first version of that A/B was not a fair test, in two ways that are worth naming because both are easy to repeat.

The second segment inherited the first one's congestion. Five seconds of saturated download leaves queues full along the path. Starting the next measurement immediately measures the tail of the previous one, which makes whichever segment ran second look worse regardless of what it was doing. There is now a 600 ms drain between them.

The second segment got half the warm-up. I had reasoned that the connection was already open and warm, so it needed less time to ramp. True of the connection — false of the six additional streams, which each start at a small congestion window and need the same number of round trips to open up. I was counting their ramp as their speed. Both segments now get the same warm-up.

That warm-up is itself scaled to the measured ping rather than fixed, for the same reason: slow start doubles the congestion window once per round trip, so the time a connection needs to reach full speed is counted in RTTs, not seconds. A flat 1.4 seconds is generous on a 10 ms link and far too short on a 120 ms one.

What I could not fix

The remaining gap against Ookla is genuinely the path, and I checked that too rather than assuming it a second time.

speed.cloudflare.com gets 24.7 Mbps from this machine — because Cloudflare serves it from their Karachi edge. My site is served from Singapore. Same CDN, same city, different datacentre, and which one answers is Cloudflare's routing, not something a site owner sets.

So the honest position is that both numbers are true and they are answers to different questions. Ookla's 28 is roughly the best this line does to a server inside the same ISP. My 15 is roughly what it does to a service hosted abroad — which, for most of what people actually load, is the more relevant number. The tool now says so on screen when the round trip is over 80 ms, instead of leaving people to conclude it is broken.

Three smaller things that also decide whether the number means anything

The payload has to be incompressible. A file of zeros gets compressed in transit, so the browser receives a few kilobytes, inflates them, and reports a connection hundreds of times faster than it is. Random bytes, and I check the generated file's own compressibility rather than trusting the RNG.

The file extension decides whether it is edge-cached. I measured this rather than reading it: on my domain, .bin, .zip, .gz, .jpg and .woff2 come back cf-cache-status: HIT, while .dat, .br and .wasm come back DYNAMIC. A DYNAMIC payload means every request travels to the origin, so you end up measuring a shared host in one fixed country instead of the user's path to the edge — and paying for the bandwidth.

xhr.upload.onprogress does not measure the wire. It fires when data enters the operating system's send buffer, which swallows several megabytes instantly. Mine reported a 90 Mbps "peak" beside a 26 Mbps average on the same eight seconds. Nobody reading those two numbers concludes they were measured differently; they conclude the tool is broken. Every upload figure now comes from byte counts the server confirms receiving.

The takeaway

The advice that more connections are faster is correct often enough to be repeated everywhere and to sound like a law. It is a heuristic about one common case, and on a congested path it inverts completely.

If you are measuring throughput, the general form of the lesson is worth more than the specific finding: when a parameter's correct value depends on conditions you cannot observe from your side, measure both and keep the winner. It costs one extra segment and removes an assumption that was silently halving my results.

The test is here if you want to see the two-segment result on your own line — it shows both figures, so you can see which way your path leans.


I build Utilorax, a set of free browser-based tools. Everything runs client-side, which produces a steady supply of problems like this one.

Top comments (0)