← Back to experiments

P2P speed test

Published on 2026-08-04

I built a speed test that does not involve a server. Two browsers open the same room, exchange enough information to find each other, and then talk directly over a WebRTC data channel while the page measures what happens. The number it reports is what the link between those two particular devices does, which is a different question from the one every other speed test answers.

The test is at /p2pst. Open it on one device, send the link to a second one, and the two of them negotiate a direct connection. It moves real data (up to a gigabyte in each direction over a full run), so it is not something to start on a phone plan.

A connection of the same kind will move a file as well. /p2pft is a second page that sends one file in either direction, out of a room it opens the way this one does, with nothing in the middle. It negotiates identically, so a network that lets the speed test connect lets that page connect too.

Most of what I learned building it was not about throughput.

What the numbers mean, and what they don't

A run is four phases. Three seconds of an idle baseline where neither side sends bulk data, ten seconds in each direction one at a time, then ten seconds of both directions at once. Each bulk phase also stops at 500 MB, so above roughly 400 Mbps the byte cap ends the phase before the clock does, which is why the card reports the duration the phase achieved rather than the one it asked for.

Four of the decisions in there are worth stating plainly, because each one is a place where the honest number and the impressive number pull in different directions.

There is no relay, and a failed run is not a bug

The page configures two STUN servers and no TURN server:

export const P2PST_ICE_SERVERS: readonly RTCIceServer[] = [
  { urls: ['stun:stun.cloudflare.com:3478'] },
  { urls: ['stun:stun.l.google.com:19302'] },
]

STUN only tells a browser what its own address looks like from the outside. It never carries the test's data. TURN would, which is exactly why there is none: a relayed run measures the relay and the two paths to it, and that is not what this page says it is reporting. Adding a relay would mean either paying for a gigabyte per run of someone else's bandwidth, or printing a number that quietly answers a different question.

So when the connection fails, that is the answer rather than a fault. One or both networks did not admit a direct path, and there is deliberately nothing underneath to catch the run. If a relay does turn up in the connected pair anyway (a corporate proxy, something else in the middle), the page says so and tells you the numbers are not worth reporting.

Testing a laptop against a phone measures the Wi-Fi

This is the mistake the page works hardest to prevent. Two devices in the same room, on the same access point, will happily report 400 Mbps. That number is real. It is also a measurement of a switch, and anyone who reads it as their internet connection has just learned something false.

So every connection carries a badge naming the kind of path it took (local network, direct over the internet, relayed, or unidentified) with the address family and transport beside it. The local-network verdict needs two observations to agree before it appears. A host-to-host candidate pair has to have carried real connectivity checks between the two devices, so the shared link is proven rather than guessed, and that has to be the pair ICE actually nominated, so the numbers standing next to the sentence were taken over the link it names.

It is message loss, not packet loss

The bulk channel sends 16 KiB messages. At a typical MTU one of those fragments into about a dozen SCTP chunks, and losing any single chunk loses the whole message. What the receiver counts is therefore messages that never arrived, which on a lossy link runs well above the share of packets the network actually dropped.

Calling that packet loss would be wrong, so the card calls it message loss and says why. The latency probes give the contrast: a probe is 12 bytes, one chunk, all or nothing in a single shot. A phase reporting 10% message loss is typically dropping on the order of 1% of probes.

One-way latency is an estimate, and says so

The card shows a one-way figure and it is half the median round trip. It is not a measurement. The two devices' clocks have no relationship to each other and a browser cannot synchronize them, so a true one-way delay is not available from here at all. The two legs are rarely equal halves of the trip in any case.

The round trip itself is sound despite those unsynchronized clocks, because of how the probes are numbered. Each side owns one parity, one sending even sequence numbers and the other odd, and each echoes back byte for byte exactly the probes whose parity it does not own. The echo carries the originator's own timestamp back unmodified, so the round trip is the difference between two readings of one clock. The other machine's clock is never read. It is copied.

One more caveat the card carries, which I would rather repeat than bury: every latency percentile is conditioned on delivery. The probe channel is unreliable, so a lost probe leaves no sample rather than a late one, and loss on a congested link is tail drop: what gets discarded is what would have waited longest. The samples that go missing are disproportionately the slow ones, which is precisely what p95 and p99 are made of. Those percentiles are a lower bound rather than a best estimate.

The number to look at is not the bandwidth

The headline figure on any speed test is throughput, and on this card it is the least useful thing there.

The idle baseline phase exists to make a different number possible. It measures the round-trip time while nothing but the probes is on the link, at twenty of them a second for three seconds. Every later phase measures the round trip again while a gigabyte is being pushed down the same link, and the card reports the difference: the loaded 95th percentile minus the idle median, in milliseconds. That is the bufferbloat delta, and it is how much extra delay the link adds when it is busy.

A connection that reports 300 Mbps and adds 900 ms of delay while it does it is a connection that stalls a video call, clips the other person's voice, and makes a shared screen unusable. The throughput headline says nothing about any of that. The delta says all of it, and most people have never seen the number for their own link.

Two things about how it is reported. It is an "at least this much" figure, because the tail-drop bias above applies here too: the probes that would have proved the queue deepest are the ones a deep queue drops. And a negative result is printed rather than clamped to zero. A loaded p95 below the idle median is unusual, but it is a real observation: a quiet stretch during the loaded phase, or a baseline taken while something else on the machine was busy. A figure that looks wrong and is true serves a reader better than one that looks right and was invented.

"Unreliable" does not mean "unbuffered"

This is the thing I had wrong going in, and I do not think I am unusual in it.

The bulk channel is configured to give up rather than retransmit:

const CHANNEL_CONFIGS = Object.freeze({
  control: undefined,
  bulk: Object.freeze({ ordered: false, maxRetransmits: 0 }),
  probe: Object.freeze({ ordered: false, maxRetransmits: 0 }),
})

The intuition that follows is that a channel like that cannot back up. If nothing is ever retransmitted, surely excess data gets dropped rather than queued, and a sender can call send() in a loop and let the channel sort itself out.

It is wrong, and the reason is precise. Partial reliability (RFC 3758 and RFC 7496) governs the abandonment of data that SCTP already holds and is waiting to retransmit. It says nothing about admission into the send queue. RFC 7496 is explicit that a message is abandoned when it "would get retransmitted for the first time", so it has to be transmitted at least once before the policy can apply to it at all. And PR-SCTP uses the same congestion control as reliable traffic, so what actually leaves the machine is gated by the peer's receive window and the congestion window exactly as it would be on a reliable channel.

Meanwhile JavaScript fills that queue orders of magnitude faster than any link drains it. An unpaced while (true) send(chunk) reaches the per-channel limit in milliseconds on an unreliable channel exactly as it does on a reliable one.

The advice about Chrome has been wrong since January 2022

The follow-on belief is that the channel will tell you when to stop: send until it throws, catch, back off. The version of this that circulates says Chrome closes your data channel once the buffer passes 16 MB.

That stopped being true in Chrome 97, in January 2022. Chrome now throws an OperationError and leaves the channel open, which is what the WebRTC specification asks for, and Safari behaves the same way. Chrome before 97 did close the channel outright, and that is where the advice comes from.

The browser that matters is the third one. Firefox has no cumulative cap at all. It never throws. It grows memory until something else gives out. So a pacing strategy built on catching an exception is paced on two engines and unbounded on the third, and the third one fails silently. Pacing has to be proactive. A try/catch around send() is a last line of defense (it also catches the InvalidStateError from a channel that closed underneath the sender), and it is not the thing keeping the queue bounded.

There is a third option that looks like it solves this and does not. Setting maxPacketLifeTime genuinely would bound the queue, because its clock starts at send() and messages that expire before transmission are discarded. It is the wrong tool for a measurement, because a self-draining queue silently throws away payload that never went anywhere: on a slow link most of the data would evaporate locally, and the result would describe the sending loop rather than the link.

What the pacing loop actually looks like

So the sender paces itself. It keeps a high-water mark of 512 KiB (32 messages at 16 KiB, well under a second of a fast link) and refills when the buffer falls to 128 KiB:

channel.bufferedAmountLowThreshold = BULK_LOW_THRESHOLD_BYTES
channel.addEventListener('bufferedamountlow', pump)
pump()

function pump() {
  while (channel.bufferedAmount < BULK_HIGH_WATER_BYTES) {
    // …stamp the sequence number and send one 16 KiB chunk
  }
}

Two things in that shape are less obvious than they look.

The loop has to start itself, which is why pump() is called directly on the line after it is registered. bufferedamountlow fires when the queue falls to the threshold, and a queue that was never filled never falls, so a sender that waited for the event before its first send would wait forever.

And the threshold is deliberately not the DOM default. That default is zero, which fires only once the queue is completely empty: a full round trip of dead air between every refill, which arrives on the results card looking like a slow link rather than like a bug in the sender. A quarter of the high-water mark leaves three quarters of the queue for the wire to work on while the refill happens.

The high-water mark is small on purpose too. Everything queued beyond what the link can carry is latency, and the probe channel will measure that latency and attribute it to the network. The mark has to be large enough that the queue never empties between wake-ups and small enough that it is not itself the bufferbloat being reported.

Why the chunk size sets a latency floor

Here is the detail I liked most. The probe channel and the bulk channel are separate SCTP streams inside one association, so they share a single congestion window. The probes are competing with a gigabyte of bulk data for the same capacity, which sounds like it should make the latency figures meaningless.

It does not, because of how the sender schedules between streams. Chrome's SCTP implementation is round-robin, with message interleaving off by default. A probe therefore waits behind at most one complete bulk message rather than behind the whole backlog. The self-inflicted head-of-line delay scales with the chunk size, not with the queue depth.

Which makes chunk size a latency decision as much as a throughput one. A larger chunk moves more bytes per call and buys a worse latency figure with them. 16 KiB happens to be the largest message that is safe across browsers anyway, so the two constraints agree here. But they are separate constraints, and on a page whose job is to measure the network's queueing rather than its own, the second is the one that binds.

What a Durable Object actually costs

The two browsers cannot find each other unaided. Something has to carry the first offer, the answer, and the ICE candidates until a direct path exists. That something is a Cloudflare Durable Object: one per room, holding at most two WebSockets. Its economics turned out to be more interesting than I expected, and worse covered elsewhere. All the figures below are the rates Cloudflare published for Workers Paid as of August 2026.

Idle connections are close to free

The room accepts its sockets through the WebSocket hibernation API rather than through server.accept(). The difference is that a hibernated object can be evicted from memory while its WebSockets stay open, so an idle connection is not billing wall-clock time for an object sitting around waiting on it.

That changes the shape of the threat model. A flood of a million idle connections costs about $0.45, essentially all of it the upgrade requests themselves, because the idle time afterwards is free. Connections are not the expensive thing. Messages are.

Incoming WebSocket messages to a Durable Object bill at 20 to 1: twenty messages count as one request. Which sounds generous until you notice the other half of it. A message is billed on arrival, whatever the handler then does with it. Deciding not to relay a frame saves nothing at all. Closing the socket is the only action that stops the arrivals, which is why every limit in this room ends in a close rather than in a throttle.

The room is self-cleaning because it never writes anything

A room has exactly two named slots and no memory beyond its sockets. Occupancy is not stored anywhere. It is derived, every time, from the object's own list of open WebSockets and a small attachment each socket carries.

That is not tidiness. A Durable Object whose storage is empty when it shuts down ceases to exist. Write one key or set one alarm and it persists instead. Durable Object instances cannot be enumerated, so a room that once wrote something can become an orphan nobody is able to find again, with deleting the whole class as the only bulk remedy. Rooms that need no sweeper, no TTL and no cleanup job are a consequence of the object having nothing to write, which is load-bearing enough that the codebase enforces it with a trap refusing every member of the storage API rather than a list of the dangerous ones.

It also rules out setAlarm() as a room expiry mechanism, because an alarm is a storage write. The room does not need one. It stops existing on its own.

The WAF cannot see WebSocket messages

This is the part most likely to be new, and it is structural rather than a configuration mistake.

Cloudflare's rate limiting rules operate on HTTP requests. A WebSocket connection begins as one HTTP request, the upgrade, and after that every frame travels on an established connection that never re-enters the ruleset engine. The edge can count how many times an address opened a socket. It cannot see anything that address then sends down it, ever.

So an edge rate limit bounds room creation and is blind by construction to a flood down a single socket. Message-volume defense has to live inside the object, because nothing else can see it. Here that means a message-size cap, a per-socket message count, per-socket and room-wide byte budgets, a token bucket with a bounded relay queue, and a closed set of message types. Each of them is the last frame a socket may send rather than the first it may not.

Six controls, five close codes. The two byte budgets share one on purpose: a browser sees only the code, the close table is frozen so a code cannot be renumbered out from under a deployed client, and "the room was handed too many bytes" is a byte-cap overrun by any reading of the other five. Which of the two tripped is in the reason string beside it.

Nothing fails closed, and I found that out late

Cloudflare offers no hard spend cap for Workers or Durable Objects. There is no budget cutoff anywhere in the billing controls. Budget alerts do exist, and they are free on a pay-as-you-go account, but Cloudflare's own wording is that they are informational and "do not pause or cap usage". Billable usage is processed a day behind as well, so the alert trails the spend it is telling you about. It is a smoke detector, not a sprinkler.

The one real ceiling Cloudflare sells is the Workers Free plan, where exceeding a daily allowance makes further operations fail with an error instead of billing. For months I wrote this section as though that were the plan this site runs on, and reasoned from there: unbounded financial risk traded for bounded availability risk, with a whole-site outage as the ugly but self-healing thing that ends an attack. I thought it was the right trade.

I had never checked. The account is on Workers Paid, and it always was. Nothing about that trade survives the correction, because on Workers Paid the allowances are included quantities rather than walls. Past them, usage bills.

AllowanceIncluded on Workers PaidPast it
Worker requests10 million / month$0.30 per million
Worker CPU time30 million CPU-ms / month$0.02 per million CPU-ms
Durable Object requests1 million / month$0.15 per million
Durable Object duration400,000 GB-s / month$12.50 per million GB-s

Plus $5 a month for the plan itself, which for a blog and an experiment page is the whole realistic bill. The tail is what changed.

Three things change with it, and only the first is obvious. Nothing fails, so there is no outage left to bound anything. The quantities are monthly rather than daily, so the UTC day boundary I had been treating as the end of an attack is not there at all. And Worker CPU time is a billed term the free reading did not have, small for this workload but not zero.

Run the arithmetic again with no ceiling in it. One laptop opening sockets as fast as it can manage is about 555 upgrades a second, which is roughly 48 million Worker requests and 48 million Durable Object requests in a day: about $14.40 and about $7.20, so about $21.60 a day, or $650 a month. That is upgrades alone, from one machine, sending not a single message down any of the sockets it opens. Messages, CPU time and duration are all on top. Nothing stops it, and nothing resets.

So the edge rate limiting rule is not a belt-and-braces measure. It is the only bound that exists. The two halves of this defense turn out to be exactly complementary, and neither one substitutes for the other. The controls inside the room are the only thing that can see messages, because the WAF cannot. The WAF rule is the only thing that can refuse an upgrade, because a request that reaches the Worker has already billed by the time anything inside the Worker gets a say.

With the rule applied, one address is rationed to ten upgrades every ten seconds. That is 86,400 upgrades a day, about four cents of them, and the per-IP total lands near $0.26 a day at the nominal send rate, once the in-object message caps have bounded everything else. The rule is the difference between $650 a month and roughly $8 a month per attacking address at that rate, or about $29 a month at ten times it. Ten times the rate is not ten times the bill, because a socket is closed on its 181st message however fast it sends them, so all the extra speed buys is a bigger overrun.

Two honest notes about that comparison. The first is that the edge counter is per data center as well as per address, so a source reaching several of them multiplies those figures by however many it reaches. The second matters more. The rule lives in the Cloudflare dashboard rather than in this repository. It was applied in August 2026 and I watched it block. After that, no commit deploys it, no green build says anything about whether it is still there, and it can be edited away without leaving a trace in the source. I wrote a script that reads the zone and compares the live rule against the recorded one field by field. I have never run it. The 1Password item it reads its token from does not exist yet, so the first run stops on the credential, and nothing has yet checked whether the two still match.

Which is the part I would want to know before copying any of this. The interesting failure was never in the code. It was that the premise the whole cost argument rested on was a sentence I wrote down once, believed for months, and never went and verified.

Why your laptop sometimes can't reach your phone

Put two devices on one Wi-Fi network and they will usually connect to each other instantly over it. When they do not, the cause is almost never the one people reach for, and it explains a frustration a lot of people have run into without ever learning the reason.

Every browser hides your local address

Every current browser hides local IP addresses behind multicast-DNS .local hostnames in its ICE candidates, so that a script cannot fingerprint a machine by its LAN address. Chrome shipped this in M76, in 2019, and Firefox and Safari default it on as well. Normally the other device resolves the name over link-local multicast, gets the address, and the connection happens.

It fails when that multicast does not reach the peer. Which is exactly what guest, hotel, conference and corporate Wi-Fi are configured to do. Client isolation is a deliberate security feature (the network is stopping guests from reaching each other, and it is working as designed), and it is why two devices a meter apart sometimes cannot exchange a single byte. Several other causes look identical from inside a browser: multicast filtering, a VLAN split between two SSIDs or between the 2.4 GHz and 5 GHz radios of one router, a VPN on either side, or macOS 26 and iOS denying the Local Network permission.

The failure is also silent, which is what makes it so confusing to hit. addIceCandidate() resolves normally for a .local name that will never resolve. Nothing throws. The connection simply finds some other path, or does not, with no indication of why.

Worth a footnote: the specification for all of this, draft-ietf-mmusic-mdns-ice-candidates, expired in 2022 and never became an RFC. Every engine implements it. There is nothing underneath it.

IPv6 rescues it, by accident of a deliberate workaround

This is the best single thing I found in the whole project.

When mDNS obfuscation is active, libwebrtc stops suppressing the server-reflexive candidate it would otherwise discard as redundant. On IPv6 there is no NAT, so the reflexive address is the host address. Which means the browser hands out the device's real, global, un-obfuscated IPv6 address in the clear, specifically so that some usable address still gets signaled.

The privacy mechanism that breaks local connectivity over IPv4 is worked around by the protocol that never needed the privacy mechanism in the first place. There is no multicast to block, so mDNS is irrelevant, and there is no NAT, so hairpinning is irrelevant. The one requirement it imposes is that the STUN servers be reachable over IPv6, which is a functional requirement rather than future-proofing, and is why both of the two above had their AAAA records checked before being chosen.

It does not defeat client isolation, which drops frames whatever address family they carry.

Hairpinning, and why a shared public address proves nothing

Two more worth a paragraph each.

When two devices sit behind one NAT and their host candidates are unusable, the only IPv4 pair left is reflexive to reflexive. Both sides are carrying the same public address. The NAT has to route a packet from the LAN back into the LAN for that to work. RFC 4787 has required this since 2007 and most current consumer firmware does it, but not all of it does, and when it does not there is no browser-side workaround at all.

That same shared-address observation is the most tempting wrong inference available on the page, which is why it is reported as a fact and never used as evidence. Carrier-grade NAT is used by a large majority of tier-1 ISPs and is effectively universal on mobile, so two entirely unrelated households routinely share one public IPv4 address, and nothing a browser can see distinguishes that from one house. Sameness of network is inferred from matching IPv6 /64 prefixes instead: a /64 is exactly one subnet, and RFC 8981 privacy addresses rotate the interface identifier while keeping the prefix, so the comparison survives an address that changes every hour.

The same discipline applies to the message shown when it all fails. Because most client isolation implementations drop multicast too, isolation and blocked mDNS collapse into a single observation. A confident wrong diagnosis sends somebody to reboot a router over a VLAN, or to change networks over a permission dialog. So the page lists what it actually observed and names no single cause.

Shelf life

A good deal of what is above will stop being true, and on a page arguing that stale advice does real damage that is worth being explicit about rather than quiet about.

The prices, the Workers Paid included quantities and the 20-to-1 message ratio are what Cloudflare published in August 2026, and pricing pages move. The browser behavior is a snapshot too: Firefox having no cumulative buffer cap, Chrome and Safari throwing at 16 MiB, and Chrome's SCTP scheduler running round-robin with message interleaving off by default are all August 2026 observations of shipping engines, not guarantees any of them make. Native IPv6 passed half of Google's users in March 2026, which is what turns the IPv6 rescue from an edge case into a majority one, and that share only moves upward.

The links above go to the primary sources on purpose (the RFCs, the IETF datatracker, the Cloudflare docs) rather than restating their numbers here as though the numbers were mine. The Chrome 97 correction is itself an example of what happens when a figure gets copied out of its source and then outlives it by four years, and I would rather this post not become the next instance of that.

And where a claim here came out of a measurement rather than out of a document (how the engines behave at the buffer limit, which candidates survive under mDNS, what a pair's state reads as mid-connection), it was measured on my own hardware, in August 2026, on one operating system and a handful of networks. Those are data points. Read them as data points.