Mobile and Native Clients
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
A native app talks to the same backends as a browser, but it lives in a harsher place: the operating system suspends it within moments of leaving the screen, the network changes under it from Wi-Fi to cellular, and a connection held open costs battery. This page shows how to build clients for the backends of this section on iOS and Android, which official and community libraries exist for SignalR, Socket.IO, LiveKit and XMPP, and which rules of the platform decide when a socket may exist at all. The wire protocol of the Chatterbox examples is defined on Message Design and Serialization; the reconnection policy that these clients implement is explained on Connection Lifecycle, Heartbeats and Reconnection.
Versions were checked on 2026-10-24: Swift 6.4 toolchain, URLSessionWebSocketTask (iOS 13 and macOS 10.15 onward),
OkHttp 5.5.1, Kotlin 2.4.21, com.microsoft.signalr:signalr 10.0.13, Socket.IO Swift client 16.1.1 (last release
2024-10-01), io.socket:socket.io-client 2.1.2, LiveKit Swift SDK 2.18.0, LiveKit Android SDK 2.29.1, Smack 4.5.0 and
XMPPFramework 4.1.0 (last release 2020-12-31).
Choosing a client for each backend
The backend decides the client. Anything that speaks plain WebSocket can use the platform socket; every framework that adds its own protocol on top (SignalR, Socket.IO, Pusher-style channels, XMPP, LiveKit signalling) needs a client library that speaks that protocol, because a plain WebSocket client cannot complete their handshakes (see Socket.IO Protocol and Clients).
| Backend | iOS and macOS | Android and JVM | Notes |
|---|---|---|---|
Chatterbox over plain WebSocket (uWebSockets.js, Spring WebFlux, Django Channels, Swoole) |
|
OkHttp |
You write the envelope handling yourself; the examples below do. |
SignalR |
Community package SignalR-Client-Swift (1.2.1, February 2026) |
|
The Java client supports WebSockets only and has no automatic reconnect; the .NET client does. |
Socket.IO |
|
|
The last releases are 2024-10-01 (Swift) and 2025-02-18 (Java); both speak protocol revision 5. |
Laravel Reverb |
Any Pusher protocol client library |
Any Pusher protocol client library |
Reverb implements the Pusher protocol; pick a maintained client and check its protocol version. |
LiveKit |
Swift SDK 2.18.0 (official) |
Android SDK 2.29.1 (official); Flutter 2.13.1; React Native 3.1.0 |
Maintained by LiveKit and releasing every few weeks. |
XMPP |
XMPPFramework 4.1.0 (TCP only) |
Smack 4.5.0 ( |
XMPPFramework has been quiet since 2020; check it against your server’s feature list. |
Cross-platform shells reuse the browser libraries where possible: React Native has a global WebSocket and runs
socket.io-client and @microsoft/signalr unchanged (see
Networking and Data Fetching), and Kotlin Multiplatform shares one
client through the Ktor client (see Networking with the Ktor Client).
The platform networking basics are on Networking: URLSession, Network Framework and ATS and
Networking with Retrofit, OkHttp and Ktor.
What a native client does differently from a browser
-
No Origin header. A browser always sends
Origin, and the server allow-lists it. A native app sends none, so the Origin check protects nothing for it and the credential has to carry the security: the one-time ticket of the Chatterbox handshake, an OAuth access token, or a signed LiveKit token. Native apps use the Authorization Code flow with PKCE and a system browser (see Native and Mobile Apps), then exchange the access token for a socket ticket over HTTPS exactly as a single-page app does (see Authentication and Authorization). -
You own the reconnect loop. A browser tab is often reloaded by the user; an app is not. Every client below keeps an outbox, retries with the same envelope id, resumes by session id and backs off with jitter.
-
Tokens go in storage the platform protects. Use the Keychain on iOS and the Android Keystore with encrypted storage, never plain preferences (see Security and Privacy and Permissions, Privacy and Security).
-
Cleartext is blocked by default. App Transport Security on iOS and the network security configuration on Android reject
ws://to non-local hosts. Usewss://everywhere except a development server on the loopback or a local network. -
The emulator is not the host. The Android emulator reaches the development machine at
10.0.2.2; the iOS simulator shares the host network and uses127.0.0.1.
A Swift client for Chatterbox
URLSessionWebSocketTask is the platform WebSocket client on Apple platforms. It is created from a URLSession, accepts a
list of subprotocols, and exchanges String or Data messages with send and receive; closing uses
cancel(with:reason:) with a close code. It needs no dependency and works on iOS, macOS, tvOS and watchOS.
The client below is an actor. It fetches a one-time ticket over HTTPS, opens the socket with the chatterbox.v1
subprotocol, performs hello (with resume data when it has a previous session), keeps every unacknowledged envelope in an
outbox keyed by id, and replays that outbox, in the original order and with the same ids, after every reconnect. The
JSON enum is the smallest type that can carry a payload object; replace it with your own Codable payload structs once
the message set is fixed.
import Foundation
/// A JSON value that can sit in an envelope payload.
enum JSON: Codable, Sendable, Equatable {
case null, bool(Bool), number(Double), string(String)
case array([JSON]), object([String: JSON])
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() { self = .null }
else if let v = try? c.decode(Bool.self) { self = .bool(v) }
else if let v = try? c.decode(Double.self) { self = .number(v) }
else if let v = try? c.decode(String.self) { self = .string(v) }
else if let v = try? c.decode([JSON].self) { self = .array(v) }
else { self = .object(try c.decode([String: JSON].self)) }
}
func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .null: try c.encodeNil()
case .bool(let v): try c.encode(v)
case .number(let v): try c.encode(v)
case .string(let v): try c.encode(v)
case .array(let v): try c.encode(v)
case .object(let v): try c.encode(v)
}
}
subscript(key: String) -> JSON? {
if case .object(let o) = self { return o[key] }
return nil
}
var string: String? { if case .string(let s) = self { return s }; return nil }
var int: Int? { if case .number(let n) = self { return Int(n) }; return nil }
var bool: Bool? { if case .bool(let b) = self { return b }; return nil }
}
/// The Chatterbox v1 envelope.
struct Envelope: Codable, Sendable {
var v = 1
var type: String
var id: String
var room: String?
var ts: Int = 0
var payload: [String: JSON] = [:]
// The protocol wants "room": null, not a missing key, so encode it explicitly.
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(v, forKey: .v)
try c.encode(type, forKey: .type)
try c.encode(id, forKey: .id)
if let room { try c.encode(room, forKey: .room) } else { try c.encodeNil(forKey: .room) }
try c.encode(ts, forKey: .ts)
try c.encode(payload, forKey: .payload)
}
}
enum ChatterboxError: Error { case ticket, closed(Int), server(code: String, retryable: Bool) }
/// One Chatterbox session over URLSessionWebSocketTask: ticket login, hello, resume, outbox and acks.
actor ChatterboxClient {
private let baseURL: URL
private let user: String
private let session = URLSession(configuration: .default)
private var task: URLSessionWebSocketTask?
private var receiver: Task<Void, Never>?
private(set) var sessionId: String?
private(set) var lastSeq: [String: Int] = [:]
private var outbox: [String: Envelope] = [:] // unacknowledged envelopes, by id
private var order: [String] = [] // original send order
private var waiting: [String: CheckedContinuation<JSON, Error>] = [:]
private var helloWaiter: CheckedContinuation<JSON, Error>?
var onBroadcast: (@Sendable (Envelope) -> Void)?
init(baseURL: URL, user: String) { self.baseURL = baseURL; self.user = user }
func setBroadcastHandler(_ h: @escaping @Sendable (Envelope) -> Void) { onBroadcast = h }
/// Opens a socket, says hello (with resume data when there is a previous session) and returns the hello reply payload.
func connect() async throws -> JSON {
var ticketRequest = URLRequest(url: baseURL.appending(path: "ticket").appending(queryItems: [.init(name: "user", value: user)]))
ticketRequest.httpMethod = "POST"
let (data, response) = try await session.data(for: ticketRequest)
guard (response as? HTTPURLResponse)?.statusCode == 200,
let ticket = try JSONDecoder().decode([String: String].self, from: data)["ticket"]
else { throw ChatterboxError.ticket }
var wsURL = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
wsURL.scheme = wsURL.scheme == "https" ? "wss" : "ws"
let task = session.webSocketTask(with: wsURL.url!, protocols: ["chatterbox.v1"])
task.maximumMessageSize = 1_048_576 // history pages may be large (server frames up to 262144 bytes)
self.task = task
task.resume()
receiver = Task { await self.receiveLoop(task) }
var hello = Envelope(type: "hello", id: Self.newId())
hello.payload["token"] = .string(ticket)
hello.payload["client"] = .string("ios/1.0")
if let sid = sessionId {
hello.payload["resume"] = .object([
"sessionId": .string(sid),
"rooms": .object(lastSeq.mapValues { .number(Double($0)) }),
])
}
let reply: JSON = try await withCheckedThrowingContinuation { cont in
helloWaiter = cont
Task { try? await self.write(hello) }
}
sessionId = reply["userId"] != nil ? reply["sessionId"]?.string : sessionId
// Anything still unacknowledged goes out again with the SAME id and in the original order.
for id in order { if let e = outbox[id] { try await write(e) } }
return reply
}
/// Sends an acked request and returns the ack payload; the id stays in the outbox until the ack arrives.
func request(type: String, room: String?, payload: [String: JSON], id: String = newId()) async throws -> JSON {
let env = Envelope(type: type, id: id, room: room, payload: payload)
outbox[id] = env; order.append(id)
return try await withCheckedThrowingContinuation { cont in
waiting[id] = cont
Task { try? await self.write(env) }
}
}
/// Simulates a lost connection (what Wi-Fi to cellular handover looks like to the app).
func dropConnection() {
task?.cancel(with: .goingAway, reason: nil)
}
func close() { task?.cancel(with: .normalClosure, reason: nil); receiver?.cancel() }
private func write(_ env: Envelope) async throws {
let text = String(decoding: try JSONEncoder().encode(env), as: UTF8.self)
try await task?.send(.string(text))
}
private func receiveLoop(_ task: URLSessionWebSocketTask) async {
do {
while true {
let message = try await task.receive()
guard case .string(let text) = message else { continue }
let env = try JSONDecoder().decode(Envelope.self, from: Data(text.utf8))
handle(env)
}
} catch {
let code = Int(task.closeCode.rawValue)
let failure = ChatterboxError.closed(code)
helloWaiter?.resume(throwing: failure); helloWaiter = nil
for (_, c) in waiting { c.resume(throwing: failure) }
waiting.removeAll()
}
}
private func handle(_ env: Envelope) {
let payload = JSON.object(env.payload)
switch env.type {
case "hello":
helloWaiter?.resume(returning: payload); helloWaiter = nil
case "ack", "error":
guard let ref = payload["ref"]?.string else { return }
outbox[ref] = nil; order.removeAll { $0 == ref }
if env.type == "error" {
let failure = ChatterboxError.server(code: payload["code"]?.string ?? "internal", retryable: payload["retryable"]?.bool ?? false)
if let w = helloWaiter { helloWaiter = nil; w.resume(throwing: failure) }
waiting.removeValue(forKey: ref)?.resume(throwing: failure)
} else {
waiting.removeValue(forKey: ref)?.resume(returning: payload)
}
default:
if let room = env.room, let seq = payload["seq"]?.int, seq > (lastSeq[room] ?? 0) {
lastSeq[room] = seq
}
onBroadcast?(env)
}
}
static func newId() -> String { UUID().uuidString.lowercased() }
}
Two details are worth reading twice. The envelope encodes room as an explicit null because the protocol requires the
key and Swift omits nil optionals by default; without the custom encode(to:) the server answers bad_request. And the
id of a message is generated by the client once and reused on retry, which is what makes a resend after a lost ack
harmless: the server answers the retry with the original ack and dup set to true.
The driver below runs the full scenario: connect, join, send, drop the connection, reconnect with resume, and send the same message id again.
import Foundation
setvbuf(stdout, nil, _IOLBF, 0)
let client = ChatterboxClient(baseURL: URL(string: "http://127.0.0.1:18900/")!, user: "alice")
await client.setBroadcastHandler { env in
print("broadcast \(env.type) room=\(env.room ?? "-") seq=\(env.payload["seq"]?.int ?? -1)")
}
let hello = try await client.connect()
print("hello userId=\(hello["userId"]?.string ?? "?") resumed=\(hello["resumed"]?.bool ?? false)")
let joined = try await client.request(type: "join", room: "general", payload: ["since": .null])
print("joined lastSeq=\(joined["result"]?["lastSeq"]?.int ?? -1)")
let fixedId = ChatterboxClient.newId()
let first = try await client.request(type: "message", room: "general", payload: ["text": .string("Hello from Swift"), "replyTo": .null, "attachments": .array([])], id: fixedId)
print("message ack seq=\(first["seq"]?.int ?? -1) dup=\(first["dup"]?.bool ?? false)")
try await Task.sleep(for: .milliseconds(200)) // let the broadcast copy of our message arrive
await client.dropConnection()
try await Task.sleep(for: .milliseconds(300))
let again = try await client.connect()
print("reconnect resumed=\(again["resumed"]?.bool ?? false)")
let retry = try await client.request(type: "message", room: "general", payload: ["text": .string("Hello from Swift"), "replyTo": .null, "attachments": .array([])], id: fixedId)
print("retry ack seq=\(retry["seq"]?.int ?? -1) dup=\(retry["dup"]?.bool ?? false)")
await client.close()
Run against the Chatterbox reference server it prints:
hello userId=alice resumed=false
joined lastSeq=1
message ack seq=2 dup=false
broadcast message room=general seq=2
reconnect resumed=true
retry ack seq=2 dup=true
The resumed=true line shows that the server kept the session across the drop, and dup=true shows that the retried
envelope was not delivered twice. A WebSocket task does not retry for you: when the network path disappears, receive
throws, and everything pending must be failed or re-queued by your code, as the receive loop above does.
A Kotlin client for Chatterbox
On Android the standard WebSocket client is OkHttp. A WebSocket is created with newWebSocket(request, listener), the
listener receives onMessage, onClosed and onFailure on a background thread, and pingInterval makes OkHttp send
WebSocket ping frames and fail the socket when pongs stop arriving. A WebSocket must be allowed to be silent between
pings, so the read timeout is set to zero. The subprotocol is requested with the Sec-WebSocket-Protocol header.
The client has the same structure as the Swift one: ticket, hello with resume, acks matched by id, an outbox replayed in
order after reconnect. It uses org.json, which is part of Android itself.
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/** One Chatterbox session over OkHttp: ticket login, hello with resume, acks and an outbox keyed by envelope id. */
class ChatterboxClient(private val baseUrl: String, private val user: String) {
private val http = OkHttpClient.Builder()
.pingInterval(20, TimeUnit.SECONDS) // WebSocket ping frames: detects a dead network path
.readTimeout(0, TimeUnit.MILLISECONDS) // a socket is allowed to be silent between pings
.build()
private var socket: WebSocket? = null
private val acks = ConcurrentHashMap<String, CompletableFuture<JSONObject>>()
private val outbox = LinkedHashMap<String, JSONObject>() // unacknowledged envelopes in send order
private val lastSeq = HashMap<String, Int>()
private var sessionId: String? = null
var onBroadcast: (JSONObject) -> Unit = {}
private fun envelope(type: String, room: String?, payload: JSONObject, id: String = UUID.randomUUID().toString()) =
JSONObject().put("v", 1).put("type", type).put("id", id)
.put("room", room ?: JSONObject.NULL).put("ts", 0).put("payload", payload)
/** Opens a socket, says hello (resuming the previous session when there is one) and returns the hello reply payload. */
fun connect(): JSONObject {
val ticketRequest = Request.Builder().url("${baseUrl}ticket?user=$user")
.post("".toRequestBody()).build()
val ticket = http.newCall(ticketRequest).execute().use { JSONObject(it.body.string()).getString("ticket") }
val helloReply = CompletableFuture<JSONObject>()
val request = Request.Builder().url(baseUrl)
.header("Sec-WebSocket-Protocol", "chatterbox.v1")
.build()
socket = http.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) = handle(JSONObject(text), helloReply)
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
helloReply.completeExceptionally(t)
acks.values.forEach { it.completeExceptionally(t) }
acks.clear()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
val closed = IllegalStateException("closed $code $reason")
helloReply.completeExceptionally(closed)
acks.values.forEach { it.completeExceptionally(closed) }
acks.clear()
}
})
val hello = JSONObject().put("token", ticket).put("client", "android/1.0")
sessionId?.let {
hello.put("resume", JSONObject().put("sessionId", it).put("rooms", JSONObject(lastSeq as Map<*, *>)))
}
socket!!.send(envelope("hello", null, hello).toString())
val reply = helloReply.get(10, TimeUnit.SECONDS)
sessionId = reply.getString("sessionId")
// Unacknowledged envelopes go out again with the SAME id, in the original order.
synchronized(outbox) { outbox.values.forEach { socket!!.send(it.toString()) } }
return reply
}
/** Sends an acked request and waits for the ack; the id stays in the outbox until the ack arrives. */
fun request(type: String, room: String?, payload: JSONObject, id: String = UUID.randomUUID().toString()): JSONObject {
val env = envelope(type, room, payload, id)
val future = CompletableFuture<JSONObject>()
acks[id] = future
synchronized(outbox) { outbox[id] = env }
socket!!.send(env.toString())
return future.get(10, TimeUnit.SECONDS)
}
/** What a Wi-Fi to cellular handover looks like to the app: the socket dies without a close handshake. */
fun dropConnection() { socket?.cancel() }
fun close() { socket?.close(1000, "bye"); http.dispatcher.executorService.shutdown(); http.connectionPool.evictAll() }
private fun handle(env: JSONObject, helloReply: CompletableFuture<JSONObject>) {
val payload = env.getJSONObject("payload")
when (env.getString("type")) {
"hello" -> helloReply.complete(payload)
"ack", "error" -> {
val ref = payload.optString("ref")
synchronized(outbox) { outbox.remove(ref) }
val future = acks.remove(ref)
if (env.getString("type") == "ack") {
future?.complete(payload)
} else {
val failure = IllegalStateException("${payload.getString("code")} retryable=${payload.getBoolean("retryable")}")
helloReply.completeExceptionally(failure)
future?.completeExceptionally(failure)
}
}
else -> {
val room = env.optString("room")
val seq = payload.optInt("seq", 0)
if (room.isNotEmpty() && seq > (lastSeq[room] ?: 0)) lastSeq[room] = seq
onBroadcast(env)
}
}
}
}
Source: OkHttp: WebSocket
import java.util.UUID
import org.json.JSONObject
fun main() {
val client = ChatterboxClient("http://127.0.0.1:18900/", "alice")
client.onBroadcast = { env -> println("broadcast ${env.getString("type")} room=${env.optString("room")} seq=${env.getJSONObject("payload").optInt("seq", -1)}") }
val hello = client.connect()
println("hello userId=${hello.getString("userId")} resumed=${hello.getBoolean("resumed")}")
val joined = client.request("join", "general", JSONObject().put("since", JSONObject.NULL))
println("joined lastSeq=${joined.getJSONObject("result").getInt("lastSeq")}")
val id = UUID.randomUUID().toString()
fun send() = client.request("message", "general", JSONObject().put("text", "Hello from Kotlin").put("replyTo", JSONObject.NULL).put("attachments", org.json.JSONArray()), id)
val first = send()
println("message ack seq=${first.getInt("seq")} dup=${first.getBoolean("dup")}")
Thread.sleep(200)
client.dropConnection()
Thread.sleep(300)
val again = client.connect()
println("reconnect resumed=${again.getBoolean("resumed")}")
val retry = send()
println("retry ack seq=${retry.getInt("seq")} dup=${retry.getBoolean("dup")}")
client.close()
}
Source: OkHttp: WebSocket
hello userId=alice resumed=false
joined lastSeq=3
message ack seq=4 dup=false
broadcast message room=general seq=4
reconnect resumed=true
retry ack seq=4 dup=true
On Android keep this class out of activities and fragments. Owning it from a ViewModel or an application-scoped
component lets a screen rotation pass without a reconnect (see
Background Work: WorkManager, Services and Alarms for what may run once the
app is not visible). The Gradle coordinate for the library is com.squareup.okhttp3:okhttp; the run above used the
okhttp-jvm artifact that Gradle resolves it to on the JVM, because Maven does not follow that redirect.
Mobile realities
Backgrounding
A phone is not a server. When the user leaves the app, the platform stops it from running freely, and the WebSocket goes with it.
-
iOS suspends an app shortly after it leaves the foreground, and a suspended app cannot answer a ping, so the server eventually closes the connection and the app finds a dead socket on return. The supported background modes (audio, VoIP, location, background fetch) do not make a chat socket legitimate; a background
URLSessionis meant for uploads and downloads, not for a live WebSocket. Close the socket cleanly when the scene goes to the background and reopen it, with resume, when it becomes active again. -
Android keeps an app’s process for a while, but Doze defers network access and alarms for an idle device, and App Standby defers background network for apps the user has not used. A long-lived socket from a background service is therefore unreliable unless it is a foreground service with a visible notification, which is justified for calls and not for a chat list.
The rule that follows is simple: the socket exists while the app is visible (or in an active call), and everything else travels as a push notification.
Push instead of an open socket
When the app is not running, the server reaches it through the platform push service: APNs on iOS and Firebase Cloud Messaging on Android (see Notifications, APNs and Live Activities and Notifications, Firebase Cloud Messaging and App Widgets (Glance)). The Chatterbox design that fits this is:
-
The client registers its push token over the open socket or an HTTPS endpoint, tied to the user and the device.
-
The server delivers a message over the socket when a connection exists, and sends a push when none does, or when the connection belongs to a session in the
awaystate (see Presence, Typing and Receipts and History, Offline Delivery and Moderation). -
The push carries a notification and, at most, an id. When the app opens, it reconnects, sends
hellowith itsresumedata, and the missed envelopes are replayed byseq, or it asks forhistory. -
End-to-end encrypted chats cannot put the text in a push; the push is a wake-up and a local notification is built after decryption (see End-to-End Encryption).
Incoming calls are the one case where Apple allows a VoIP push: since iOS 13 an app that receives a PushKit VoIP push must immediately report a call to CallKit, or the system terminates it and eventually stops delivering VoIP pushes. Use it for calls only, never for chat messages. On Android a high-priority FCM message is the equivalent, and it starts a foreground service for the call (see the LiveKit section below). Android may downgrade a high-priority message to normal priority when the app does not use it for time-sensitive content, so check the priority received before starting the service.
Network handover
A phone moves between Wi-Fi and cellular while a socket is open. The TCP connection does not survive the change of IP
address, but nothing tells the socket so: the next write simply fails after a long timeout. The remedy is to watch the
network path yourself and replace the socket immediately when the path changes. On iOS the Network framework offers
NWPathMonitor; on Android a ConnectivityManager.NetworkCallback reports onAvailable and onLost.
The supervisor below turns three inputs (scene visible, path usable, interface changed) into one decision: should a socket exist now. It is deliberately separate from the transport so the same policy can drive any of the clients on this page.
import Network
/// Decides when the chat socket should exist. The transport itself stays in ChatterboxClient.
@MainActor
final class ConnectionSupervisor {
enum Wish { case open, closed }
private let monitor = NWPathMonitor()
private var pathUsable = false
private var foreground = false
private var lastInterface: NWInterface.InterfaceType?
private let onChange: (Wish) -> Void
private let onHandover: () -> Void
init(onChange: @escaping (Wish) -> Void, onHandover: @escaping () -> Void) {
self.onChange = onChange
self.onHandover = onHandover
monitor.pathUpdateHandler = { [weak self] path in
let usable = path.status == .satisfied
let interface = path.availableInterfaces.first { path.usesInterfaceType($0.type) }?.type
Task { @MainActor in self?.pathChanged(usable: usable, interface: interface) }
}
monitor.start(queue: DispatchQueue(label: "chatterbox.path"))
}
func sceneBecameActive() { foreground = true; evaluate() }
func sceneEnteredBackground() { foreground = false; evaluate() } // close cleanly instead of waiting to be suspended
private func pathChanged(usable: Bool, interface: NWInterface.InterfaceType?) {
let handover = usable && pathUsable && interface != lastInterface
pathUsable = usable
lastInterface = interface
if handover { onHandover() } // the old socket is dead even if TCP has not noticed yet: replace it now
evaluate()
}
private func evaluate() { onChange(foreground && pathUsable ? .open : .closed) }
}
Call sceneBecameActive() from the SwiftUI scenePhase change or from the scene delegate, and have onChange open or
close the client. onHandover should cancel the current task and reconnect at once, without the backoff delay, because the
failure was not the server’s.
When a reconnect is needed after a handover, the first attempt is immediate and later ones back off with full jitter. A thousand phones that regain coverage together in a train tunnel are a reconnection storm for the server (see Scaling WebSockets).
Battery and data
An idle WebSocket costs little by itself; the cost is the radio. Every ping and every heartbeat wakes the cellular modem, and the modem stays in a high-power state for seconds afterwards. So:
-
Use the longest heartbeat the infrastructure allows. Chatterbox advertises
heartbeatMsin thehelloreply, so a server can ask mobile clients for a longer interval than web clients (proxy idle timeouts set the ceiling; see Proxies, Load Balancers and Deployment). -
Do not send
typingevents while the app is in the background, and batchreceiptenvelopes (they are cumulative by design). -
Prefer one socket for the whole app over one per feature.
-
Release the camera and microphone as soon as a call ends, and lower video resolution and frame rate on cellular (see Stats, Quality and Bandwidth).
-
Close the socket when the app goes to the background instead of waiting for the system to suspend it.
The libraries of the other backends
SignalR
Microsoft ships the Java client, which also runs on Android, and the .NET client, which runs in MAUI. The Java client
supports the WebSocket transport only (no fallback to Server-Sent Events or long polling), takes the access token from a
function so a fresh token is used on every connect, and has no automatic reconnect: when the connection closes, onClosed
is called and the app has to build a new connection. The code below does that with full jitter.
import com.microsoft.signalr.HubConnection
import com.microsoft.signalr.HubConnectionBuilder
import io.reactivex.rxjava3.core.Single
import kotlin.random.Random
/** The Java client has no automatic reconnect: rebuild the loop yourself in onClosed. */
class HubSession(private val hubUrl: String, private val tokenSource: () -> String) {
private var attempt = 0
lateinit var connection: HubConnection
fun start() {
connection = HubConnectionBuilder.create(hubUrl)
.withAccessTokenProvider(Single.defer { Single.just(tokenSource()) }) // asked for a token on every (re)connect
.withKeepAliveInterval(15_000)
.withServerTimeout(30_000)
.build()
connection.on("Receive", { text: String -> println("received $text") }, String::class.java)
connection.onClosed { error ->
if (error != null) scheduleReconnect() // null means we called stop()
}
connection.start().subscribe({ attempt = 0 }, { scheduleReconnect() })
}
private fun scheduleReconnect() {
val ceiling = minOf(30_000L, 1_000L shl minOf(attempt++, 5))
val delay = Random.nextLong(ceiling + 1) // full jitter
Thread { Thread.sleep(delay); start() }.start()
}
}
The .NET client has WithAutomaticReconnect() on its builder and, with .NET 8 and later, stateful reconnect that buffers
messages across a short drop (see SignalR for both). For Swift, Microsoft documents
no client; the community package SignalR-Client-Swift is a separate project, so verify that it supports the features your hub needs (client results, streaming, reconnect)
before committing to it.
Socket.IO
The Swift client builds a SocketManager once and takes a SocketIOClient from it; the Java client is created with
IO.socket(url, options). In both, force the WebSocket transport when the server runs several nodes without sticky
sessions, send the credential in the CONNECT packet (connect(withPayload:) in Swift, auth in the options in Java)
instead of the URL, and tune the reconnection options, which are on by default with delay, maximum delay and a
randomization factor for jitter.
import Foundation
import SocketIO
let manager = SocketManager(
socketURL: URL(string: "http://127.0.0.1:18901")!,
config: [
.compress,
.forceWebsockets(true), // skip long-polling
.reconnects(true),
.reconnectWait(1), // seconds
.reconnectWaitMax(30),
.randomizationFactor(0.5), // jitter
.log(false),
]
)
let socket = manager.defaultSocket
socket.on(clientEvent: .connect) { _, _ in
print("connect")
socket.emitWithAck("chat", ["text": "Hello from Swift"]).timingOut(after: 5) { data in
print("ack \(data)")
}
}
socket.on(clientEvent: .error) { data, _ in print("error \(data)") }
socket.on("chat") { data, _ in
print("chat \(data)")
socket.disconnect()
exit(0)
}
socket.connect(withPayload: ["token": "example-ticket-alice"]) // sent in the CONNECT packet
RunLoop.main.run(until: Date().addingTimeInterval(10))
print("timeout")
exit(1)
Source: Socket.IO: Client API
import io.socket.client.IO
import io.socket.client.Socket
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.json.JSONObject
fun main() {
val options = IO.Options().apply {
transports = arrayOf("websocket") // skip long-polling: no sticky sessions needed
auth = mapOf("token" to "example-ticket-alice") // sent in the CONNECT packet, not in the URL
reconnection = true
reconnectionDelay = 1_000
reconnectionDelayMax = 30_000
randomizationFactor = 0.5 // jitter
}
val socket = IO.socket("http://127.0.0.1:18901", options)
val connected = CountDownLatch(1)
val received = CountDownLatch(1)
socket.on(Socket.EVENT_CONNECT) { println("connect id=${socket.id()}"); connected.countDown() }
socket.on(Socket.EVENT_CONNECT_ERROR) { println("connect_error ${it.firstOrNull()}") }
socket.on("chat") { args -> println("chat ${(args[0] as JSONObject).getString("text")}"); received.countDown() }
socket.connect()
check(connected.await(5, TimeUnit.SECONDS)) { "no connect" }
val acked = CountDownLatch(1)
socket.emit("chat", JSONObject().put("text", "Hello from Kotlin"), io.socket.client.Ack { println("ack ${it.firstOrNull()}"); acked.countDown() })
check(acked.await(5, TimeUnit.SECONDS)) { "no ack" }
check(received.await(5, TimeUnit.SECONDS)) { "no broadcast" }
socket.disconnect()
}
Source: Socket.IO: Client API
Both clients connected to a Socket.IO 4.8 server whose middleware checks handshake.auth.token, joined a room, received
the broadcast of their own event and got the acknowledgement. Connection state recovery works from native clients too, but
only inside the server’s maxDisconnectionDuration; after a long background period the app must assume a fresh
connection and re-join its rooms (see Socket.IO).
The verified Swift package builds its target in Swift 5 language mode (swiftLanguageMode(.v5)), because the callbacks
of the library are not annotated for strict concurrency. Both libraries have been quiet since 2024 and 2025; for a new app consider whether a plain WebSocket and the
Chatterbox envelope are enough before adding a dependency.
LiveKit
LiveKit provides first-party SDKs for Swift (iOS, macOS, tvOS, visionOS), Android, Flutter and React Native, all
connecting with the same URL and access token that the browser SDK uses (see
LiveKit). The token is minted by your server and must never be embedded in the
app. The Swift SDK is added as the client-sdk-swift package; an app needs NSCameraUsageDescription and
NSMicrophoneUsageDescription in its Info.plist, and the audio background mode so a call keeps its audio when the user
leaves the app. The Android SDK is io.livekit:livekit-android and needs the CAMERA and RECORD_AUDIO permissions at
runtime.
import LiveKit
@MainActor
final class HuddleCall: RoomDelegate {
let room = Room()
init() { room.add(delegate: self) }
func join(url: String, token: String) async throws {
try await room.connect(url: url, token: token)
try await room.localParticipant.setMicrophone(enabled: true)
}
nonisolated func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState, from oldValue: ConnectionState) {
print("state \(oldValue) -> \(connectionState)")
}
}
print("compiled")
Source: LiveKit docs: Swift SDK
On Android the equivalent is LiveKit.create(applicationContext) followed by room.connect(url, token) and enabling the
microphone through room.localParticipant. Keep the room in a service-scoped object, not in an activity, and run it as a
foreground service with a notification while a call is active; without one, Android may stop the process when the screen
is off. For incoming calls combine this with CallKit and a VoIP push on iOS and a high-priority FCM message on Android.
XMPP
Smack is the standard Java and Android XMPP library. It connects over TCP, supports Stream Management (XEP-0198) so a stream can resume after a short drop, a ping manager (XEP-0199), and its own reconnection manager; a WebSocket module for OkHttp exists for networks that only allow HTTPS (see XMPP Fundamentals and XMPP Servers). On iOS, XMPPFramework speaks XMPP over TCP, so the server must expose its TCP client port (5222).
import org.jivesoftware.smack.ReconnectionManager
import org.jivesoftware.smack.chat2.ChatManager
import org.jivesoftware.smack.packet.Presence
import org.jivesoftware.smack.tcp.XMPPTCPConnection
import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration
import org.jivesoftware.smackx.ping.PingManager
import org.jxmpp.jid.impl.JidCreate
fun openXmppSession(host: String, user: String, password: String): XMPPTCPConnection {
val config = XMPPTCPConnectionConfiguration.builder()
.setXmppDomain("chat.example.test")
.setHost(host)
.setUsernameAndPassword(user, password)
.setSendPresence(false) // announce presence ourselves, once the UI is visible
.build()
val connection = XMPPTCPConnection(config)
connection.setUseStreamManagement(true) // XEP-0198: resume the stream after a short drop
connection.setUseStreamManagementResumption(true)
ReconnectionManager.getInstanceFor(connection).enableAutomaticReconnection()
PingManager.getInstanceFor(connection).pingInterval = 120 // seconds (XEP-0199)
ChatManager.getInstanceFor(connection).addIncomingListener { from, message, _ ->
println("${from}: ${message.body}")
}
connection.connect().login()
connection.sendStanza(connection.stanzaFactory.buildPresenceStanza().ofType(Presence.Type.available).build())
ChatManager.getInstanceFor(connection)
.chatWith(JidCreate.entityBareFrom("bob@chat.example.test"))
.send("Hello from Smack")
return connection
}
Source: Ignite Realtime: Smack
For push on XMPP mobile clients, servers implement XEP-0357 (Push Notifications): the server sends a push to a service the client registered, so the TCP connection can be dropped when the app sleeps.
Testing on devices and simulators
-
Simulate bad networks. The Network Link Conditioner (Xcode Additional Tools) on Apple platforms, the emulator network controls and
adb shellthrottling on Android, and the server-sidetc netemapproach from Building a Multiplayer Game all work. -
Force Doze.
adb shell dumpsys deviceidle force-idleputs a device in Doze, andadb shell dumpsys deviceidle unforceleaves it; use it to see what the socket and the foreground service do. -
Test the handover. Toggle airplane mode with Wi-Fi on, then switch Wi-Fi off, while the app is open and while a call is active; the supervisor must reconnect once, not repeatedly.
-
Run the protocol checks without a phone. The clients in this page talk to the reference server of Message Design and Serialization, so their logic can be driven by a scripted scenario in CI before any device test. Browser tests of the same backends are on Testing Real-Time Apps and E2E Testing in the Browser.
Common errors and pitfalls
-
bad_requeston hello from a Swift client. The envelope was encoded without theroomkey set tonull; Swift omits nil optionals unlessencode(to:)writes them. -
Messages delivered twice after a reconnect. The retry generated a new id. Reuse the id of the unacknowledged envelope.
-
The socket looks connected after a handover but nothing arrives. No path monitoring and no application heartbeat. TCP alone takes minutes to notice.
-
Hello timeout (close 4408) after the app returns from the background. The old socket was kept; open a new one and resume.
-
Connection refused from the Android emulator.
127.0.0.1is the emulator itself; use10.0.2.2for the host. -
Cleartext traffic not permitted. Android and iOS block
ws://outside the development exceptions; usewss://. -
Reconnect storm after an outage. No jitter, or a fixed delay. Use exponential backoff with full jitter.
-
The VoIP push crashes the app. A PushKit VoIP push was received without reporting a CallKit call. Never use VoIP pushes for anything but calls.
-
A foreground service dies on Android 14 and later. The service type was not declared in the manifest or the permission for the type is missing (
MissingForegroundServiceTypeExceptionorSecurityException). -
Token expiry during a long call or session. The access token for the socket expired; mint a new ticket and use the protocol’s re-auth path (see Authentication and Authorization).
Version and platform notes
-
URLSessionWebSocketTaskrequires iOS 13 or macOS 10.15; the code above uses the asyncsendandreceivevariants, so check your deployment target against the Swift concurrency back-deployment notes. -
OkHttp 5 splits the library into platform artifacts; Gradle resolves
okhttpto the right one, Maven needsokhttp-jvmor the Android artifact explicitly. -
The Socket.IO Swift client and the SignalR Java client are both configured for WebSocket only here; if a server needs long polling fallback, the Java SignalR client cannot connect.
-
Smack 4.5.0 changed how stanzas are built (builders and
StanzaFactory); older examples that callnew Presence(type)do not compile. -
Treat the VoIP and foreground service rules as moving: both platforms tighten them in most releases, so re-read the platform notes for the OS version you target.
Summary
A native client is the same protocol as the web client plus the policies the platform forces on it: no Origin, so rely on
tickets and tokens; one supervisor that decides when a socket should exist from visibility and network path; an outbox that
retries with the same id; resume by session and seq; and push notifications for everything that happens while the app is
asleep. The Swift and Kotlin clients above implement the Chatterbox contract completely enough to pass the round trip
against the reference server. Continue with
Comparison and Decision Guide to pick the backend, and with
Security for the checklist that applies to native clients as well.