android

Why Android Calling Is a Distributed System (and How to Fix It)

Ever tap a ringing phone only to watch the call vanish? You are not imagining it. Android calling is fragile because most teams treat it as a single feature, when in practice it is a small distributed system spread across the network, the app, and the operating system itself.

This is the first post in a five-part series on building a production-grade calling stack, based on real lessons from delivering the Stream Video Android SDK. The series walks through failure modes that surface when ringing, push notifications, and the system Telecom layer are not properly coordinated—and introduces the mechanism that holds everything together: an explicit call state machine.

The three places a call lives at once

A true Android calling system spans three independent layers:

Backend (events):** WebSocket signaling and push notifications fire call events. These arrive late, arrive twice, or arrive out of order.

App (CallService + state):** Your application runtime manages the in-memory representation of the call, starts foreground services, and updates UI.

System (Telecom + OS lifecycle):** The moment you register a call with `CallsManager.addCall(…)`, Android itself holds a copy. The user can answer or reject from a smartwatch, Bluetooth headset, or lock screen—none of which know anything about your app's internal state. Those actions arrive via Telecom callbacks, not your WebSocket.

Each layer updates independently, can be temporally inconsistent, and may contain outdated information. They do not share a clock. They do not guarantee ordering. They fail on their own schedules.

What breaks when these layers fall out of sync

Without a central authority, familiar bugs accumulate:

  • Incoming calls that never appear on the lock screen
  • `ForegroundServiceDidNotStartInTimeException` crashes
  • Ringing that continues on a device that already accepted the call elsewhere
  • Camera and microphone indicators that remain active after a call ends
  • Orphaned messages that survive a hangup

These are not edge cases. They are what happens when call flows are not properly controlled.

A concrete bug timeline

Consider this sequence:

  • T0:** Call starts on the server
  • T1:** WebSocket event arrives; app shows ringing
  • T2:** Call is accepted on a second device
  • T3:** A delayed push notification finally reaches the first device
  • T4:** First device rings again for a conversation that no longer exists

Each event is technically correct in isolation. Together they produce a user-facing ghost call. The fix is not to make events more reliable—it is to make state the source of truth.

Events are signals; state is truth

Most calling implementations treat incoming events as ground truth. They assume push messages and WebSocket frames arrive in order and reflect the current situation. In a distributed setup, that assumption fails. Messages lag, duplicates appear, and the order shuffles.

The alternative is to validate incoming events against a centralized state machine before doing anything. Route every event—whether from WebSocket, FCM, or Telecom callbacks—through one decision authority. That authority alone determines whether the transition is legal, what state to enter, and which side effects to fire.

The call state machine

Without explicit state ownership, you end up with disconnected handlers, hidden transitions, and failures that resist diagnosis.

The fix is an explicit, validated state machine that controls every transition. A practical starting point uses these states:

“`

Idle → Incoming → Outgoing → Active → Ended

“`

  • Idle:** No active call
  • Incoming:** Validated incoming call; user is being notified
  • Outgoing:** User is dialing; awaiting callee
  • Active:** Call is connected
  • Ended:** Cleanup runs exactly once

Side effects live outside the state machine but are triggered by its transitions. Showing the incoming UI fires when entering Incoming. Stopping the foreground service fires when entering Ended. If a push arrives after Ended, the state machine discards it—ghost rings cannot happen.

Who should care

Role Why it matters
SDK developers Own the state machine contract; bugs here cascade to every consumer
App developers integrating a calling SDK Route all events through one handler; do not bypass the machine
Backend engineers Push timing is unreliable; design for out-of-order delivery
QA teams Test race conditions: late pushes, duplicate events, multi-device acceptance

What to watch next

The next posts in this series cover Telecom framework integration and cases where the OS—not your app—ends the call. The core principle is already clear: any feature with multiple inputs, multiple devices, and a user-visible lifecycle needs a single owner of the truth. For Android calling, that owner is the state machine.

If you ship calling code without one, expect to fight the same bugs next quarter—just on different devices.

Key takeaways

  • Android calling spans three independent layers that do not share timing or guarantees
  • Treating calls as "receive event → show UI" produces scattered handlers and production failures
  • Events are signals; state is truth—validate before acting
  • An explicit state machine (Idle → Incoming → Outgoing → Active → Ended) centralizes all decisions
  • Side effects are bound to state transitions, not to individual event handlers
  • Telecom callbacks are a first-class input, not a workaround

Frequently asked questions

Why does Android make calling harder than it looks?

Android adds background execution limits, process death, foreground service startup delays, notification permission constraints, and the Telecom Framework. Each layer can act independently of your code, and none share a clock or guarantee ordering.

What does a call state machine actually do?

It is a single decision authority that validates every incoming event, owns the current call state, and triggers side effects only on legal transitions. Late, duplicate, or out-of-order events cannot corrupt the call lifecycle because the machine simply ignores invalid transitions.

What is the Telecom Framework's role in this?

Once you register a call with `CallsManager.addCall(…)`, the OS holds its own representation. Users can answer, reject, or end calls from smartwatches, Bluetooth headsets, or the lock screen. These actions arrive via Telecom callbacks—your WebSocket never sees them.

Do I need this if I am only using a third-party calling SDK?

The state machine pattern applies any time you wrap or extend a calling product. If you are routing SDK events into your own UI or services, you still need a central authority to prevent race conditions across WebSocket, push, and user actions.

What is the first step to apply this to my app?

Define a small set of explicit call states (Idle, Incoming, Outgoing, Active, Ended are a solid starting point) and route every incoming event—whether from WebSocket, FCM, or Telecom callbacks—through one handler that validates the transition before updating UI, services, or notifications.

Related