Skip to content
8 min read

The list you provisioned is not the list your endpoint uses

You provisioned a relay menu with several transports and configured every one of them. Between the provider's response and the peer connection, your own code reduces it to a single entry — silently, because the call still connects and every counter records a success.

Vyacheslav Pankratov· Fullstack Developer
Cover art for “The list you provisioned is not the list your endpoint uses”
On this page

TL;DR: You provision an ICE server list with several relay entries — different transports, different ports, a TLS one for hostile networks — and you provisioned all of them. Between the provider's response and the browser's peer connection sits code you own, and in the systems we read it reduces that list to exactly one entry. Every reduction is a plain assignment where an append was meant. Nothing fails: negotiation succeeds on whatever survived, the call connects, and the only evidence is a menu that arrives shorter than it left.

Relay configuration is bought once, tested once, and never looked at again. It sits in the category of things that are either right or catastrophically wrong, and a call that connects is taken as proof of the first.

That reading is wrong in a specific way. A relay list is not a switch; it is a menu, and the whole point of having several entries is that networks differ. Reduce the menu and calls still connect — just not the ones that needed the entry you removed.

  1. The provider returns the menuseveral transports, several ports
  2. A copy loop assigns, not appendsonly the last survives; TLS matches nothing
  3. A lookup stores the resolved addressone A record, pinned at join
  4. Negotiation succeedson whatever arrived — recorded as a success
One menu; one hop that removes two entries at once — the last write wins, and the TLS scheme matches no branch at all; one that pins what is left to a single address. The fourth removes nothing: it reports a success, on whatever arrived. That is why the narrowing has no observer.

Where does the list get shorter?

Three places along one path, in one system, none of them looking like an edit.

The first is a loop that reads the provider's response and copies it into the shape a media server wants:

for (let url of urls) {
    if (url.startsWith('stun:')) {
        xml.stun = url;
    } else if (url.startsWith('turn:')) {
        const [_, host, port] = url.split(':');
        xml.turnAddress = 'turn:turn:' + host;
        xml.turnPort = port;
    }
}

Read the assignments. xml.stun = url and xml.turnAddress = ... are not appends into a collection — they are single fields being written. The loop runs over every URL the provider returned and each one overwrites the last. Whatever survives is whichever matching entry came last in the array — and the qualifier matters, because the entry that is genuinely last is the one this loop cannot see at all.

Which entry does that actually drop?

More than the ordering suggests, because one scheme matches neither branch.

A TLS relay URL begins turns:, and that is not a variant spelling — it is a separate scheme. RFC 7065 is explicit: "The 'turns' URI scheme MUST be used when TURN is run over TLS-over-TCP … and the 'turn' scheme MUST be used otherwise."

Now read the test again. "turns:host".startsWith("turn:") is false — the fifth character is s, not the colon the comparison requires. So the turns: entry matches neither branch and is discarded without a trace. The transport specifically provisioned for networks that block everything else is the one entry guaranteed not to survive the copy, and it is guaranteed by a single character.

The same line has a second casualty. A relay URL may carry its transport as a query parameter, and split(':') on turn:host:3478?transport=udp yields ['turn', 'host', '3478?transport=udp'] — so the port field now holds the port with a query string welded to it. That is the dangerous shape rather than the obvious one: anything that parses it as a number gets 3478 and looks right, while the concatenation this code actually does further down gets the whole string.

And the address is built as 'turn:turn:' + host, with the scheme doubled. That is not a typo that broke anything, which is the interesting part: further along, the receiving code strips it with replace('turn:', '') called twice in succession. Two halves of one system, each compensating for the other's mistake, both correct in the sense that the call still connects.

What happens to the one entry that survives?

It gets pinned.

The server that receives the address does a DNS lookup and stores the resolved IP rather than the name. The relay is now a single A record, chosen at the moment one user joined, for the life of that session. A relay deployment behind several addresses, or one that fails over by DNS, has been reduced to whichever answer came back first.

It also closes the door the turns: entry was already shut out of: what was stored is the address, not the name, and a certificate issued for the relay's hostname has nothing left to match. Even if the scheme had survived the loop, it could not have survived the lookup.

There is a smaller defect in the same function worth naming, because it is the kind that produces irreproducible reports. The port and the credentials are stored synchronously; the address is stored inside the DNS callback. A session that reaches the connection step before that callback returns has a port and a password and no address.

Is there a third one?

Yes, and it is the plainest of them. At the other end of the same system, an endpoint fetches the provider's list and edits it before forwarding:

const replace = body;
replace.v.iceServers.urls[0] = 'stun:stun.example.invalid:3478';
writeResponseJSON(response, 200, replace);

Element zero of the provisioned array is overwritten with a hardcoded public STUN address. Whatever the provider put there — and it is a paid provider, chosen and configured deliberately — is gone before any client sees it. Beneath it sits a commented-out version of the same thing with a DNS lookup, which tells you this was iterated on rather than typed once.

There is no dashboard for this

Nothing in the system holds the provisioned list and the forwarded list at the same time, so nothing can subtract one from the other. A metric that compares them only gets written by somebody who already suspects — which is the one thing a working call trains everybody out of.

Why does nothing report it?

Because the system's definition of success is "the call connected", and it did.

ICE is built to succeed on whatever it is given. Hand it one candidate and it will use one candidate. There is no error, no warning, and no metric anywhere that compares the list you provisioned against the list that arrived — because writing that comparison requires someone to have suspected it was needed.

What you provision What is actually used What reports the difference
several relay transports the last turn: entry in array order nothing
a TLS relay for hostile networks discarded silently nothing
a hostname that can fail over one A record, pinned at join nothing
the provider's first entry a hardcoded public address nothing

The failure surfaces as a shape in the aggregate rather than as an event: a proportion of users on restrictive networks whose calls do not work, distributed across the places those networks are — a hospital, a corporate VPN, a country. Each individual case looks like a bad network, which is the explanation everyone reaches for and the one that ends the investigation.

How would you know if this were true of you?

Do not read the configuration. Read the transit.

  1. Find every place the provisioned list is copied, transformed or forwarded. Between the provider response and new RTCPeerConnection there are usually more hops than anyone remembers.
  2. At each hop, ask: append or assign? A loop that writes to a scalar field is a reduction wearing the shape of an iteration.
  3. Grep for turns:. If your provider offers TLS relay and the string appears nowhere in your codebase, it is not being used, whatever your provider's dashboard shows.
  4. Print the list at the last hop before the peer connection, and compare it — literally, entry by entry — with what the provider returned. This is the check, and it is the only one that cannot be argued with.
  5. Then ask what you are paying for. You are buying transports your code is not forwarding, and nothing on either side of that arrangement will tell you.

FAQ

Would this not show up as ICE failures? Rarely, and that is what makes it durable. A missing candidate causes no failure at all for anyone who did not need it. It causes failure only for the users whose networks required the entry that was dropped — and for them it is indistinguishable from the network being at fault, which it partly is.

Is one relay entry not enough? It is enough for whoever tested it. That is exactly why the reduction survives review. Multiple entries exist for the networks that need a different transport, and those are not the networks anyone in the building is sitting behind.

Should we validate the list at startup instead? It helps and it is not sufficient. Validation at the source confirms what you provisioned; every defect described here happens after that, in transit. The comparison has to be at the last hop before the peer connection, because that is the only place the two lists can be put side by side.

Is this a WebRTC problem? No, and it would be convenient if it were. Every one of these four is ordinary application code doing ordinary things — a copy loop, a prefix, a DNS lookup, a default. The protocol did what it was told.

The three things worth taking away

A loop that assigns is a reduction. Iterating over a collection and writing each element into a single field keeps the last one and discards the rest, and it reads exactly like handling all of them.

The entry provisioned for the hardest networks is the one most likely to be dropped, because it is the one whose scheme differs by a character and whose users are the least likely to be in the room when it is tested.

Success is the reason nobody looks. The call connects, so the configuration is assumed correct — and the only way to find out otherwise is to compare the list you paid for with the list that arrived, which nobody does until someone suggests it.

If you provision relay transports you have never seen used, that is a cheap thing to have looked at.

Was this helpful?

// Build it

Running this in production?

Real-time video breaks in the places a demo never reaches — negotiation, weak networks, the transcoding bill. Tell us what breaks and where; we reply within a day with a concrete next step.