Skip to content
12 min read

An offer with no deadline is an appointment that never ends

The offer goes out and the server parks on a promise that only the answer resolves. Nothing sets a deadline, nothing evicts the entry, and the symptom is the least actionable one there is: a screen that says connecting.

Vyacheslav Pankratov· Fullstack Developer
Cover art for “An offer with no deadline is an appointment that never ends”
On this page

TL;DR: A signalling server takes an offer, forwards it, and parks on a promise that only the matching answer can resolve. There is no signalling timeout anywhere in that layer — no deadline on the wait, no eviction of an abandoned entry, no bound on the table. The only clock in the call belongs to the browser, several hops away, and it does not start when you sent the offer. We measured what that costs: every wait leaks, permanently, and the teardown mode makes no difference at all — which is exactly why you cannot reproduce it by pulling a cable.

Everything about this failure is quiet. Nobody gets an error. No request fails, because after the offer there is no request outstanding that anyone is watching. The server is healthy, the answerer is healthy, and the only artefact is a word on a screen that does not change.

"Connecting" is the least actionable symptom in real-time software. It names a state, not a cause, and it is the state a call is supposed to pass through. By the time anyone decides it has lasted too long, the evidence of why is gone.

What does the wait actually look like in code?

Two systems we have built implement the rendezvous the same way, in different stack generations — one hand-rolled against a peer connection, one a client for a media server — and between them the shape appears at four separate call sites. They never met. The shape is stable enough to quote as a shape:

// on offer
processOffer(userId, { callId, sdp }) {
  sendToOpponent(...);
  return new Promise((resolve) => { this.table[userId] = resolve; });
}

// on answer
processAnswer(userId, { callId, sdp }) {
  const resolve = this.table[getOpponent(userId, callId)];
  if (!resolve) return;
  delete this.table[...];
  resolve(sdp);
}

Read what is not there. There is no reject, so the promise has one exit and the answer owns it. There is no timer. There is no eviction: an entry leaves the table when a matching answer arrives, and in no other circumstance. There is no bound on the table's size. And this.table[userId] = resolve is an assignment, which will matter later.

One of the two is worse than a missing timeout. Its explicit stop path — the code that runs when a user closes the tab — sets the resolver to null without calling it, so a deliberate teardown leaves the waiting caller parked exactly as a crash would.

  • graceful close50 of 50
  • reset50 of 50
  • silent blackhole50 of 50
  • control — nothing severed0 of 50
Fifty signalling waits per arm, and three identical bars are the result. The control severs nothing and resolves all fifty; every other mode leaks all fifty, whichever way the connection was cut — so reproducing a disconnect tells you nothing here, and the reproduction that finds a stale-cleanup bug reads the same in all three.

Why can't you reproduce it by pulling a cable?

This is the part that decides whether an engineer can find it, so we measured it rather than reasoning about it. rig/rendezvous drives that table behind an in-process relay that severs the answerer's connection three ways: a graceful close, a reset, and a silent blackhole that leaves both sockets open and reports nothing. Fifty rendezvous per arm, answers taking 250 ms, the cut landing at 40 ms so every wait is genuinely outstanding when it arrives.

Teardown Waits still parked Table entries left Resolved Recovered?
none (control) 0 0 50 yes
graceful close 50 50 0 no
reset 50 50 0 no
silent blackhole 50 50 0 no

The teardown mode makes no difference, and that is the finding. We have published the opposite result for a different defect: a cleanup that deletes by key erases the wrong entry on 0% of graceful closes, 0% of resets and 100% of silent disappearances, because that bug turns entirely on whether a departure is announced. Here nothing turns on it. The waiter is not watching the socket — it is parked on a promise, and no close of any kind reaches a promise.

So the two defects are indistinguishable from outside: the call does not connect. And the diagnostic instinct that works on the first one — sever a connection in a way production would and watch what happens — produces the same reading for all three modes here, which tells you nothing.

The control arm is the important row. It severs nothing, so it must resolve all fifty, and it does. Without it the other three rows would be unreadable: a harness that stalls for its own reasons produces exactly the same table.

  1. First offera resolver is stored under the key
  2. The caller parks on itcorrect — this is the design
  3. Second offer, same keythe assignment replaces it
  4. The answer resolves the new onecounters normal, first caller stranded
  5. The second answer finds an empty tabledropped — nothing logs, counts or retries it
Nothing is severed and nothing fails. The second offer replaces the first resolver in a plain assignment, the answer settles the newest one, and every counter reads normal while the first caller waits on something no longer reachable.

The leak that needs no failure at all

Sever nothing. Leave the connection perfectly healthy and let every answer arrive. Then let one user press the call button twice.

Offers per key Resolvers orphaned Answers that found a resolver Answers dropped Waits left parked
1 0 50 0 0
2 50 50 50 50
3 100 50 100 100

this.table[userId] = resolve overwrites. The second offer drops the first resolver on the floor — the entry is gone from the table, and the caller that was waiting on it is still waiting, with nothing left that can ever resolve it. One orphan per repeat, exactly.

Now read the two middle columns together, because either one alone is misleading. A hundred answers came back for fifty keys. The first one per key found a resolver and deleted the entry; the other fifty arrived for a rendezvous that no longer existed and were dropped without a single counter moving. Nothing logged them, nothing counted them, nothing retried them.

So the resolved column is the number of keys, not the number of answers, and at fifty it reads exactly as it would if nothing were wrong. The newest resolver settles, the earlier caller is parked forever behind a screen that says connecting, and the system has nowhere to put the fact that half of its answers landed on an empty table.

This is why the remedy is not "add a timeout". A deadline turns that permanent hang into a slow one. It does not stop the overwrite, and the second caller in a double-tap still strands the first.

That number is not the deadline you think it is

The 5000 ms you found is a grace period for trickled candidates once ICE checking is already underway— not a deadline on the answer, and not a clock that starts when you sent the offer. A signalling layer that adopts it as “the time an answer has” is measuring from the wrong instant.

Whose deadline is it, then?

Not yours — you did not set one. So the call is governed by whatever the endpoints do, and at least one of them is explicit about it. Firefox ships media.peerconnection.ice.trickle_grace_period, and the default is one line in the pref file: pref("media.peerconnection.ice.trickle_grace_period", 5000);. A bug filed against it is titled "WebRTC: ICE failed on small timeout for answer transmission (5s)" — an answer carried by hand, and a call that failed for it.

Be careful with that number, because it is not what it looks like — and the place to check is the implementation, not the pref name. The timer is armed at the top of nr_ice_peer_ctx_start_checks2(), under the comment "Ensure that grace period timer is running" — that is, when connectivity checks begin — and armed again by a newly paired trickled candidate if it is not already running. When it expires it does not cancel checks that are still in flight: it logs "Trickle grace period is over; marking every component with only failed pairs as failed" and marks as failed only the components whose pairs have all already failed. That is how a late answer becomes "ICE failed" — the bug above exists because it does — and it is also why the timer is not a deadline on the answer: it ends the wait for late candidates that could have rescued pairs which had already lost.

So the clock does not start when you sent the offer, and it is not a budget for the answer's journey. A signalling layer that adopted 5000 ms as "the time an answer has" would be measuring from the wrong instant, against the wrong event.

The bug is RESOLVED WONTFIX, and the reason is worth reading because it is a trade rather than an oversight — Byron Campen, comment 3: "We have this timeout to allow better detection of ICE failures; the copy/paste/email use-case is not as important to us. If you want, you can change the media.peerconnection.ice.trickle_grace_period pref to something longer." The browser deliberately prefers finding out quickly that a call has failed over accommodating a slow rendezvous. That is a defensible choice, and note who the second sentence is addressed to: the pref belongs to the person running the browser, not to the service that needed the longer wait. The choice is being made on your behalf, in a component you do not ship and cannot configure.

Now try to find the equivalent number for another browser. The one that circulates traces back to one sentence on a mailing list in November 2013: "For Chrome it's most likely issue 2563, currently there is a 30s timeout on ice candidates." It is hedged by its author, twelve years old, and about candidates rather than about the answer; the tracker issue it names has been migrated twice since and now lives behind a different ID. We are not publishing it as a figure, because it is not one.

That absence is the argument, not a gap in it. The layer whose job is to bring two peers together has no deadline of its own, inherits one from a component several hops away, and the inherited number is difficult to state precisely, cannot be stated at all for some endpoints, and starts from a different instant than the one you care about.

What does a deadline cost?

The usual objection to adding one is that it will cut off answerers who were going to reply. So we swept it: a deadline against a spread of answer latencies, with a severed arm to count rescues and an unsevered control to count the answerers a deadline killed that would have succeeded.

Deadline Rescued (severed) False kills (healthy)
none 0 0
50 ms 50 5
100 ms 50 3
250 ms 50 1
500 ms 50 0
2 s 50 0

The band is wide. Every deadline from 50 ms up rescued the whole severed population, while false kills fell away as it grew. The spread of answer latencies here is chosen, not observed, and only about five answers per arm are eligible to be killed at all — so neighbouring rows differ by less than the run-to-run variation. Read the direction, never the gap between two rows: the cost of a deadline falls off steeply, and the objection is cheaper to answer than it sounds.

What the deadline must do when it fires is the part worth arguing about. Resolving the promise with nothing is the wrong move — the caller cannot tell "no answer" from "an empty answer". Reject it with a named error the caller can act on, evict the entry, and let the client decide whether to retry or tell the user something true.

The five-minute check

You do not need the harness to find out whether you have this.

  1. Find the wait. Search the signalling layer for a promise constructed with a resolver captured into a map, dictionary or table. One or two hits is normal; that is the rendezvous.
  2. Ask what ends it. If the only path to settling is a matching inbound message, you have this. The tell is a new Promise with one parameter — a resolve and no reject.
  3. Ask what evicts it. Find every delete or remove on that table. If they all sit on the success path, an abandoned rendezvous is a permanent row.
  4. Check the assignment. If adding is table[key] = resolver rather than a guarded insert, a repeat offer on a live key orphans the first waiter, and no timeout will fix that.
  5. Check the stop path. If the teardown code nulls the resolver instead of calling it, closing a tab strands the peer as thoroughly as a crash.

Steps 4 and 5 are the ones that find something on a healthy system. Steps 1 to 3 find what a bad network exposes; those two find what a double-tap exposes.

FAQ

Is this the same as an ICE failure? No, and the distinction is the useful part. ICE failing means two peers tried to find a path and could not. This is the rendezvous before that: one peer never received the other's answer, so no path was ever attempted. Both present as a call that does not start, which is why they get conflated.

Does a keepalive on the signalling socket solve it? No. The socket can be perfectly healthy — in the blackhole arm above it was, and the wait leaked anyway. The waiter is not attached to the socket's health. A keepalive tells you the transport is alive, which was never the question.

Does this need multiple servers to happen? No. Everything here is one process. There is a multi-replica version of this shape, where the offer and the answer land on different instances and the second one finds no resolver at all — but that is a different article about a different invariant, and this one is not it.

Is a long timeout not safer than a short one? Only against one failure. A long deadline reduces false kills and leaves the caller staring at "connecting" for its whole duration. The rig's point is that the safe band is wide enough that you can have both — and that whatever you pick, saying so beats inheriting a number you never read.

The three things worth taking away

A wait with no deadline is not a slow wait, it is a permanent one. Nothing recovers it. The goroutine, the table entry and the caller all persist for the life of the process, and every arm we severed left all fifty behind.

The teardown mode decides nothing here, and that is diagnostic. The reproduction that finds a stale-cleanup bug — sever a connection the way production does — reads identically across a graceful close, a reset and a silent blackhole. If your investigation depends on reproducing a disconnect, this class is invisible to it.

A deadline is necessary and not sufficient. It cannot see the overwrite, which needs no failure at all: one user, two taps, one stranded caller, and every counter in the system reading normal.

If your logs say a call was established and your users say it never connected, that is the kind of thing we come in for.

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.