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)

URLSessionWebSocketTask, or Network framework

OkHttp WebSocket, or Ktor client

You write the envelope handling yourself; the examples below do.

SignalR

Community package SignalR-Client-Swift (1.2.1, February 2026)

com.microsoft.signalr:signalr 10.0.13 (Microsoft); .NET client for MAUI

The Java client supports WebSockets only and has no automatic reconnect; the .NET client does.

Socket.IO

socket.io-client-swift 16.1.1 (official, built on Starscream)

io.socket:socket.io-client 2.1.2 (official, built on OkHttp)

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 (smack-android, TCP; smack-websocket-okhttp for WebSocket)

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. Use wss:// 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 uses 127.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.

ChatterboxClient.swift
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.

main.swift
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.

ChatterboxClient.kt
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)
            }
        }
    }
}
Main.kt
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()
}
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 URLSession is 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:

  1. The client registers its push token over the open socket or an HTTPS endpoint, tied to the user and the device.

  2. 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 away state (see Presence, Typing and Receipts and History, Offline Delivery and Moderation).

  3. The push carries a notification and, at most, an id. When the app opens, it reconnects, sends hello with its resume data, and the missed envelopes are replayed by seq, or it asks for history.

  4. 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.

Supervisor.swift
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 heartbeatMs in the hello reply, 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 typing events while the app is in the background, and batch receipt envelopes (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.

HuddleHub.kt
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.

Swift
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)
Kotlin
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()
}

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.

HuddleCall.swift
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")

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).

XmppSession.kt
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
}

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 shell throttling on Android, and the server-side tc netem approach from Building a Multiplayer Game all work.

  • Force Doze. adb shell dumpsys deviceidle force-idle puts a device in Doze, and adb shell dumpsys deviceidle unforce leaves 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_request on hello from a Swift client. The envelope was encoded without the room key set to null; Swift omits nil optionals unless encode(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.1 is the emulator itself; use 10.0.2.2 for the host.

  • Cleartext traffic not permitted. Android and iOS block ws:// outside the development exceptions; use wss://.

  • 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 (MissingForegroundServiceTypeException or SecurityException).

  • 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

  • URLSessionWebSocketTask requires iOS 13 or macOS 10.15; the code above uses the async send and receive variants, so check your deployment target against the Swift concurrency back-deployment notes.

  • OkHttp 5 splits the library into platform artifacts; Gradle resolves okhttp to the right one, Maven needs okhttp-jvm or 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 call new 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.