The report was oddly specific. Type the session pin, press Next, and sometimes the button greys out and nothing happens. Not an error. Not a spinner that never stops. Just a dead button, and the only way forward is to close the dialog and start again.

The code that runs when you press Next looks fine:

socket.emit("collab:join", payload, (res) => {
  if (!res.ok) { showError(res.error); return; }
  // ...
});

The button is disabled while that request is in flight and enabled again when it finishes. Standard. The problem is the word "finishes".

There is no timeout

A Socket.IO acknowledgement is a callback the server invokes when it has handled your event. If the server handles it, the callback runs. If the server never handles it, the callback never runs. That is the whole contract. There is no built-in deadline, no error branch, no rejection.

So any of these leaves the callback pending forever:

  • The connection dropped between the emit and the reply.
  • The server restarted mid-request.
  • The handler threw before reaching ack().
  • A phone moved from wifi to mobile data.

None of those are rare. The last one happens to somebody every few minutes on a mobile site.

Why it shows up as a dead button

Our button helper wraps the work in a promise and restores the button in a finally block. That is the right shape: success or failure, the button comes back.

But finally only runs when the promise settles. A promise whose resolve function is sitting inside a callback that will never be invoked does not settle. It is not rejected. It is not pending on anything the runtime can see. It simply waits, and the button waits with it.

This is worth sitting with, because it is the general failure mode of promise-wrapped callbacks: a callback that is never called is not an error you can catch. Your error handling can be perfect and still never run.

The fix that does not scale

The obvious fix is a timeout per call:

const timer = setTimeout(() => finish(false), 12000);
socket.emit("collab:join", payload, (res) => {
  clearTimeout(timer);
  // ...
});

Correct, and unmaintainable. We had over thirty emits with acknowledgements. Every one would need the same four lines, and every one added later would need somebody to remember.

Do it once, at the socket

The emit is the thing with the flaw, so the emit is where it gets fixed. Wrapping it means every call site is covered, including the ones that do not exist yet:

function guardAcknowledgements(socket, ms) {
  const send = socket.emit.bind(socket);
  socket.emit = function (...args) {
    const event = String(args[0] || "");
    const ack = args[args.length - 1];
    if (!event.startsWith("collab:") || typeof ack !== "function") {
      return send(...args);
    }
    let answered = false;
    const giveUp = setTimeout(() => {
      if (answered) return;
      answered = true;
      ack({ ok: false, error: "The server did not answer." });
    }, ms);
    args[args.length - 1] = (...reply) => {
      if (answered) return;
      answered = true;
      clearTimeout(giveUp);
      ack(...reply);
    };
    return send(...args);
  };
}

Three details in there matter more than they look.

The refusal is shaped like a real one. It is an object with ok: false and an error string, because that is what every existing handler already knows how to display. Not one call site had to change.

The answered flag guards both directions. A late reply arriving after the timeout is dropped. Without it a slow server would call your callback twice, which is a worse bug than the one being fixed.

Only our own events are touched. The prefix check means Socket.IO's internal traffic passes through untouched. Wrapping emit is a blunt instrument and it is worth narrowing the blast radius.

Choosing the number

We used twelve seconds. Short enough that nobody sits there wondering, long enough that a slow mobile connection is not cut off mid-request.

There is a real trade-off underneath it. If the reply arrives at thirteen seconds, we ignore it — so the server might have accepted a join while the screen says it failed. That is rare at twelve seconds, and retrying works. A permanently dead button does not.

The general lesson

Anywhere you turn a callback into a promise, ask what happens if the callback is never called. Acknowledgements, message handlers, native bridges, anything with a completion function you did not write. If the answer is "the promise never settles", you have a dead button waiting to be reported.