← Writing

The socket is not the call

I spent three years building the iOS and Android clients for a phone system. The hardest thing in either app was not the audio, the video, or the interface. It was that at any given moment there are three different opinions about whether you are on a call — the operating system's, the app's, and the websocket's — and a phone spends its whole life doing things that make them disagree.

Three sources of truth

On iOS, an app that handles calls does not draw its own incoming-call screen. It hands the call to the system, which shows the native call interface, takes over the lock screen, routes audio, and manages interruptions against the cellular network. That is the right design — it means an internet call behaves like a normal one — but it means the OS now holds a copy of your call state and will act on it.

Underneath, the actual signalling runs over a websocket: invitations, participants joining and leaving, someone muting, the call ending. And the app holds its own model of what is happening, because it has a screen to draw.

When all three agree, nothing is interesting. The work is in the gaps.

The transport dies, the call does not

Here is the failure that taught me the most. A user is in a meeting. The websocket drops — they walked into a lift, the network handed over from wifi to cellular, the server restarted. The app notices. The system does not, because as far as it knows there is a call in progress and nobody has told it otherwise.

So the native call interface stays on screen. The lock screen shows an active call. Audio routing stays seized. The user taps the red button and it does nothing useful, because the thing that would normally end the call is a message over a socket that no longer exists.

From the user's point of view their phone is stuck in a call that is not happening. The only reliable escape is force-quitting the app, which is not a feature.

The fix is not subtle once you see it, but it has to be applied in every direction: whenever the transport fails, the OS-level call must be torn down explicitly — on a disconnect the user did not initiate, on a socket that fails to open at all, and on any close for any other reason. The app has to treat "my socket has gone" as an event that propagates outward into system state, not just an error it logs and shows a spinner for.

The same applies to the ordinary end-of-call path: ending a call has to invalidate its timers and clear system state even if the socket is already closed. The obvious implementation ends the call by sending a message and cleaning up when the response arrives, which works perfectly right up until the reason you are ending the call is that there is nothing to send a message to.

Recovery needs to know when to stop

Once you have automatic reconnection, you have a new class of bug: reconnecting to things you have deliberately left.

A user leaves a meeting. The app tears down its session. The recovery logic, which is watching the socket and does not know why it closed, dutifully reconnects — and now the user is back in a meeting they left, or holding a connection that keeps something alive on the server that should have been released.

Reconnection logic needs to distinguish between "this closed and I want it back" and "this closed because I closed it". That sounds obvious written down. It is easy to miss because the two cases look identical at the point where you notice: the socket is closed. The information that distinguishes them lives in your intent, which has to be recorded deliberately rather than inferred.

Reconnecting is not the same as being connected

The instinct is to treat reconnection as success. The socket is back, so we are fine.

But a real-time connection carries state, and while it was down the world moved on. Messages arrived. People joined and left. Somebody muted. Simply restoring the transport leaves the app confidently displaying a stale picture, which is arguably worse than showing a disconnected state, because it looks correct.

So reconnection has to trigger reconciliation — fetch what was missed, re-establish subscriptions, and repair the model before telling the user everything is fine. In practice that means the reconnect path is not "open a socket" but "open a socket, then find out what you missed", and the second half is most of the work.

The operating system will suspend you

A mobile app does not get to hold a connection open because it would like to. When the app goes to the background the OS suspends it, and a socket you believe is open is in an undefined state of "maybe".

Leaving it nominally open causes two problems. Other parts of the app cannot cleanly use the transport during the shutdown window, because something is still holding it. And on resume you have a connection that may be alive, may be dead, and cannot be distinguished without trying.

Closing it properly on backgrounding, and bringing it up on demand — when a call arrives — is more code than leaving it running, and it is the only version that behaves predictably. It also stops the app maintaining a connection permanently for a feature the user might use twice a week, which the battery notices.

Smaller things that each cost a day

What I took from it

When another system holds a copy of your state, every failure path has to reach it. The OS call interface is not a view you render; it is a second state machine you are responsible for keeping in step, including when the thing you would normally use to update it is the thing that broke.

Record intent, do not infer it. "Socket closed" is not a reason. Recovery logic that cannot tell a failure from a deliberate teardown will eventually do the wrong one, and the bug will present as something reconnecting when it should not — which nobody reads as a reconnection bug.

A restored connection is an empty one. Treat reconnection as the beginning of recovery rather than the end of it, and assume everything you were showing is stale until refreshed.

Mobile networks are not unreliable networks — they are hostile ones. Handovers, suspension, interruption by the cellular stack, and an OS that will kill you for holding resources. Desktop real-time code assumes the connection is normally up. Mobile real-time code has to assume it is normally about to go down, and be boring about it every time.

Also in this series