Why Your Peer-to-Peer Transfer Says "Connecting" Forever

Most guides stop at "sometimes P2P doesn't work." That isn't useful when you're staring at a spinner. Here are the five distinct failures, how to tell them apart, and what fixes each one.

August 7, 2026 10 min read

The problem NAT creates

Your laptop almost certainly does not have its own address on the public internet. It has a private address — something like 192.168.1.14 — that means nothing outside your home or office. Your router holds the one public address, and it translates between the two. That translation is called NAT, Network Address Translation, and it is the reason the internet did not run out of addresses a decade ago.

NAT works beautifully when you are the one starting the conversation. You request a web page, your router notes that the reply belongs to your laptop, and the reply gets home. It falls apart when two machines both behind NAT need to talk to each other directly, because neither has an address the other can reach and neither router has any reason to expect the incoming packet.

Every peer-to-peer file transfer in a browser has to solve this before a single byte of your file moves. Usually it does. When it doesn't, the interesting question is why not — because the answer determines whether waiting helps, whether retrying helps, or whether nothing helps.

How hole punching normally succeeds

The trick relies on a server that does almost nothing. A STUN server's entire job is to answer one question: from where you are sitting, what address and port did this packet appear to come from? Your browser asks, gets told 203.0.113.7:54321, and now knows how it looks from the outside.

Both browsers gather these candidate addresses and exchange them through a signalling channel — for us, a WebSocket that exists only to make the introduction. Then both start sending packets at each other simultaneously. The outbound packet from each side creates a temporary opening in its own router's translation table, and the inbound packet from the other side arrives just in time to look like a reply to something legitimate. Both routers let it through. The hole has been punched, and from that moment the two browsers talk directly, with no server in the path.

This works because most home routers use what is called cone NAT: when your laptop sends from internal port 54321, the router maps it to one external port and keeps that mapping for anyone who replies. Predictable mapping is what makes the whole scheme possible.

Symmetric NAT: the one that cannot be punched

Some networks assign a different external port for every destination you contact. Send to the STUN server, you get port 54321. Send to your peer thirty milliseconds later and the router picks 62187 instead. This is symmetric NAT, and it defeats hole punching completely.

The reason is worth stating plainly, because it explains why retrying is pointless. The address your browser learned from STUN is the mapping for talking to the STUN server. It tells you nothing about the mapping the router will create for your peer, because that mapping does not exist yet and will be different when it does. You are exchanging an address that is already wrong. Trying again produces a new address that is wrong in a new way.

Symmetric NAT is common in exactly the places people try to send files from: corporate networks, university campuses, hotel Wi-Fi, and hospital or government systems. It is usually a deliberate security posture rather than a misconfiguration, which also means nobody is going to change it for you.

Mobile networks add their own version. Carrier-grade NAT puts thousands of subscribers behind a shared pool of public addresses, adding a second layer of translation above the one your phone already has. Some carriers handle this in a way that still permits hole punching; many do not, and it varies by carrier and sometimes by region within the same carrier.

Five failures that look identical from outside

Here is the part most write-ups skip. "Connection failed" is not one condition. In our own transfer code we distinguish five, and we learned to only after shipping a single message for all of them — which meant the message was usually wrong.

The original text blamed payment for a relay. Four of the five failures happen after a payment has already succeeded, so the one explanation always shown was also the one that was usually untrue. That matters more than it sounds: the device that fails is often a phone, whose browser console nobody can reach, so the on-screen message is the entire diagnostic.

  • No direct route, and no relay attempted. The genuine symmetric-NAT case. ICE gathers candidates, none of the pairs connect, and the connection reports failure. Nothing about the transfer is broken; the network simply has no path.
  • A relay was needed but not authorised. A relay costs bandwidth, so it is not switched on unconditionally. This is the only one of the five where the answer really is about payment.
  • The relay allocation expired or was used up. Relay credentials are deliberately short-lived. A transfer that pauses long enough can come back to find its allocation gone — already paid for, but no longer valid.
  • Credentials were refused by the relay provider. Authorisation succeeded on our side and the provider still declined to issue a working allocation. From the user's seat this is indistinguishable from the previous case, which is precisely why it needs its own message.
  • The relay came up but the data channel never opened. The rarest and most confusing. Credentials were issued and the transport switched, and the channel that actually carries file bytes still failed to establish — usually a renegotiation the existing connection refused.

If you build anything on WebRTC, separating these is worth the afternoon it costs. Every one of them needs a different response from the user, and collapsing them into one message guarantees most people are told to do the wrong thing.

How to tell which one you hit

In the browser, the authoritative source is RTCPeerConnection.getStats(). Find the candidate pair whose state is succeeded and look at the local candidate's candidateType. A value of host means you connected over the local network. srflx, server reflexive, means hole punching worked. relay means the direct attempt failed and traffic is going through a TURN server.

One caveat that cost us real debugging time: read the candidate type from the candidate line itself rather than from a convenience property on the stats object. Browsers differ in which fields they populate, and a property that is present in one browser and absent in another produces a verdict that is confidently wrong rather than obviously missing.

A second caveat, if you are measuring this to make a decision: do not judge from a sample taken during the handshake. Early in a connection the only traffic is ICE negotiation, so a reading taken then describes the handshake rather than the transfer. Wait until real data is flowing.

What actually fixes it

For a genuine no-direct-route failure, in the order worth trying:

  • Switch one device to mobile data. The single most effective fix, and the one people try last. You only need one end of the connection to be on a network that permits hole punching. Turning off Wi-Fi on a phone often resolves it in seconds.
  • Put both devices on the same network. If they share Wi-Fi, the connection can often be made locally and never leaves the building — the fastest possible case, because it runs at LAN speed rather than internet speed.
  • Try from a home connection. Consumer routers are far more permissive than corporate ones. A transfer that is impossible from an office network frequently just works from a flat.
  • Use a relay. When none of the above is available, a TURN server forwards the encrypted stream between the two peers. It always works, because it replaces the impossible direct path with two ordinary outbound connections. It costs real bandwidth to operate, which is why it is the last option rather than the default — we wrote about what a relay actually costs to run separately.

What does not help: retrying on the same networks, restarting the browser, or waiting. If the path is blocked by NAT topology, it is blocked deterministically. A tool that responds to this by suggesting you try again is wasting your time, and we deliberately don't.

Why this is a permanent trade-off

It would be convenient to treat this as a bug awaiting a fix. It isn't. The property that makes peer-to-peer transfer private — that your file goes straight to the recipient and never rests on anyone's server — is the same property that makes it dependent on a direct path existing. A service that routes everything through its own infrastructure never has this problem, and in exchange it has a copy of your file.

Knowing which failure you are looking at is what turns a mystery into a thirty-second fix. Most of the time, that fix is toggling off Wi-Fi on one phone.

Related Articles