Skip to content

Case study

A phone that lives in the browser

Frontend Engineer · softphone and real-timeOct 2022 — present3 min read

  • SIP.js
  • WebRTC
  • React
  • TypeScript
A SIP.js softphone inside a support console: registration lifecycle, call state and teardown handled in the browser, with no desktop client to install.

Context

Support agents worked a queue in a web console and took calls in a separate desktop softphone. Two applications, two windows, and no shared context: the phone did not know who the caller was and the console did not know a call was happening. Every call started with an agent reading a number off one screen and searching for it in another.

The work was to put the phone inside the console — a real one, on SIP, with audio in the browser.

Constraint

It has to be a phone, not a demo of a phone. Registration, inbound and outbound calls, hold, transfer, teardown, and the dozen states in between. A softphone that drops a call in front of a customer is worse than no softphone.

The browser is not a phone platform. Microphone permission is a prompt the user can refuse. Tabs get backgrounded, throttled and closed. Media devices change mid-call when someone unplugs a headset.

Networks are hostile. Agents work from offices, homes and hotspots. Registration has to survive the network dropping out and coming back, and it has to survive it without a page reload.

Approach

Model the call as an explicit state machine. Idle, registering, registered, ringing, connecting, in-call, on-hold, terminating — each with the transitions that are legal from it and no others. The alternative, which is what a first version usually looks like, is a handful of booleans that can express states no phone can actually be in.

Keep signalling and media apart. SIP session lifecycle is one concern; the media stream, the audio element and device selection are another. They fail independently and they recover differently, so conflating them means every bug in one looks like a bug in the other.

Treat registration as continuous, not as setup. Registration is not a thing that happens once at login; it is a thing that lapses, retries and re-establishes for as long as the tab is open. Backing off on repeated failure rather than retrying tightly is what stops a flaky connection turning into a request flood.

Make the phone's state visible to the console. Once call state lives in the application rather than in a separate program, the queue can react to it — the agent's record is on screen when the call connects, because the same application knows both facts.

Trade-offs

A tab is a fragile host for a phone. Closing it ends the call, and no amount of engineering fully fixes that — a beforeunload warning is a mitigation, not a solution. A desktop client does not have this problem. What it does have is an install, an update channel, and no access to the console's context.

Browser media handling is uneven. Device enumeration, permission behaviour and echo cancellation differ between browsers, and some of it cannot be fixed from application code. Supporting fewer browsers well is a better trade than supporting all of them badly.

An explicit state machine is more code up front. It is more verbose than booleans on the day it is written, and it is the reason the tenth edge case does not require rereading the whole module.

Everything real about this depends on a live SIP gateway, which means it cannot be reproduced on a portfolio site — the reason there is a case study here and not a demo.

Outcome

Agents take calls in the same window they work the queue in, with no separate client to install, update or alt-tab to. Because call state lives in the application, the customer's record is on screen when the call connects rather than after the agent has searched for it.

The state machine turned out to be the load-bearing decision. Most of what arrived later — hold, transfer, reconnection after a network drop — was a new transition rather than a new set of flags to reconcile against the existing ones.

What I'd change today

I would build the state machine before the happy path, not after it. The first version grew out of booleans, and the rewrite into explicit states was work that a day of design would have avoided. The tell was already there early: two flags that could not legally be true at the same time, and nothing in the code that said so.

I would separate device management from call management sooner. Headset changes, permission revocation and default-device switching all arrived as call bugs because they were handled inside the call code. They are not call concerns; they are device concerns that a call happens to depend on.

I would invest in a replayable log earlier. Telephony bugs are reported as "the call dropped" with no reproduction, and the only way to answer that is a timeline of signalling events with the state at each one. Building that after the first unreproducible incident cost more than building it before the first call.