Skip to main content
The LiveKit Swift SDK is built with Swift 6 concurrency and data-race safety. Understanding how concurrency works in the SDK is essential for building robust applications.

Concurrency Model

The SDK uses:
  • async/await for asynchronous operations
  • actors for synchronization and isolated state
  • @Sendable types for safe cross-thread data passing
  • StateSync for thread-safe state management
  • Background threads for most operations (not @MainActor)

Async/Await Usage

All primary SDK methods are async:
// Connecting to room
try await room.connect(url: wsURL, token: token)

// Publishing tracks
try await room.localParticipant.setCamera(enabled: true)
try await room.localParticipant.setMicrophone(enabled: true)

// Publishing data
try await room.localParticipant.publish(data: data)

// Disconnecting
await room.disconnect()

Calling Async Methods

From an async context:
func joinRoom() async throws {
    try await room.connect(url: wsURL, token: token)
    try await room.localParticipant.setCamera(enabled: true)
}
From a synchronous context:
func joinRoomSync() {
    Task {
        do {
            try await room.connect(url: wsURL, token: token)
        } catch {
            print("Failed to connect: \(error)")
        }
    }
}
From SwiftUI:
struct ContentView: View {
    @State private var room = Room()
    
    var body: some View {
        Button("Join Room") {
            Task {
                try await room.connect(url: wsURL, token: token)
            }
        }
    }
}

Thread Safety

State Access

The SDK uses StateSync for thread-safe state access:
// Reading state (thread-safe)
let connectionState = room.connectionState
let participants = room.remoteParticipants

// State is protected internally
let isCameraEnabled = room.localParticipant.isCameraEnabled()

Delegates and Callbacks

Delegate methods are called on background threads, not the main thread:
class MyRoomDelegate: RoomDelegate {
    func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState) {
        // ⚠️ This is called on a background thread
        print("Connection state: \(connectionState)")
        
        // Update UI on main thread
        DispatchQueue.main.async {
            self.updateConnectionUI(connectionState)
        }
    }
}
Or use MainActor:
func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState) {
    Task { @MainActor in
        self.updateConnectionUI(connectionState)
    }
}

SwiftUI Integration

For SwiftUI, use @MainActor or dispatch to main:
class RoomViewModel: ObservableObject {
    @Published var connectionState: ConnectionState = .disconnected
    
    init() {
        room.add(delegate: self)
    }
}

extension RoomViewModel: RoomDelegate {
    func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState) {
        // Update @Published property on main thread
        Task { @MainActor in
            self.connectionState = connectionState
        }
    }
}

Actors in the SDK

The SDK uses actors for synchronization:
// SignalClient is an actor
let signalClient = room.signalClient

// Methods on actors are async
await signalClient.send(message: message)

Actor Isolation

You cannot directly access actor state:
// ❌ This won't compile - actor state requires async access
// let value = actor.someProperty

// ✅ Access actor state via async method
let value = await actor.getSomeProperty()

Tasks and Cancellation

The SDK uses Task for background work:
// Start a long-running task
let task = Task {
    try await room.connect(url: wsURL, token: token)
}

// Cancel if needed
task.cancel()

Cooperative Cancellation

Check for cancellation in long-running operations:
func processVideoFrames() async throws {
    while !Task.isCancelled {
        try Task.checkCancellation()  // Throws if cancelled
        
        // Process frame
        await processFrame()
    }
}

Task Cancellables

The SDK uses AnyTaskCancellable for managing tasks:
var cancellable: AnyTaskCancellable?

func startOperation() {
    cancellable = Task {
        try await longRunningOperation()
    }.cancellable()
}

func stopOperation() {
    cancellable?.cancel()
}

Sendable Types

Most SDK types conform to Sendable for safe cross-thread usage:
// These are safe to pass across threads
let room: Room  // @unchecked Sendable
let participant: Participant  // @unchecked Sendable
let track: Track  // @unchecked Sendable

Custom Delegates

Make your delegates Sendable:
class MyRoomDelegate: RoomDelegate, @unchecked Sendable {
    // Implement delegate methods
}
Or mark the delegate as isolated to a global actor:
@MainActor
class MyRoomDelegate: RoomDelegate {
    // All methods called on MainActor
}

Common Patterns

Sequential Operations

Perform operations in sequence:
func setupRoom() async throws {
    try await room.connect(url: wsURL, token: token)
    try await room.localParticipant.setCamera(enabled: true)
    try await room.localParticipant.setMicrophone(enabled: true)
    print("Setup complete")
}

Parallel Operations

Run independent operations concurrently:
func setupMediaTracks() async throws {
    async let camera: Void = room.localParticipant.setCamera(enabled: true)
    async let microphone: Void = room.localParticipant.setMicrophone(enabled: true)
    
    // Wait for both to complete
    try await (camera, microphone)
}
Or use TaskGroup:
func publishMultipleTracks() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask {
            try await room.localParticipant.setCamera(enabled: true)
        }
        group.addTask {
            try await room.localParticipant.setMicrophone(enabled: true)
        }
        
        // Wait for all tasks
        try await group.waitForAll()
    }
}

Error Handling with Async

func connectWithRetry(maxAttempts: Int = 3) async {
    for attempt in 1...maxAttempts {
        do {
            try await room.connect(url: wsURL, token: token)
            print("Connected successfully")
            return
        } catch {
            print("Attempt \(attempt) failed: \(error)")
            if attempt < maxAttempts {
                try? await Task.sleep(nanoseconds: 2_000_000_000)
            }
        }
    }
    print("Failed to connect after \(maxAttempts) attempts")
}

Avoiding Race Conditions

Don’t Access Shared State Without Synchronization

// ❌ Race condition - multiple threads can modify
var isConnected = false

Task {
    try await room.connect(url: wsURL, token: token)
    isConnected = true  // ⚠️ Not thread-safe
}

Task {
    if isConnected {  // ⚠️ Not thread-safe
        print("Connected")
    }
}
// ✅ Use actor for synchronization
actor ConnectionState {
    private(set) var isConnected = false
    
    func setConnected(_ connected: Bool) {
        isConnected = connected
    }
}

let state = ConnectionState()

Task {
    try await room.connect(url: wsURL, token: token)
    await state.setConnected(true)
}

Task {
    if await state.isConnected {
        print("Connected")
    }
}

Use SDK State Instead of Caching

// ❌ Don't cache state separately
var cachedParticipants: [RemoteParticipant] = []

// ✅ Use SDK state
let participants = room.remoteParticipants

WebRTC Thread Safety

WebRTC operations must use the LiveKit WebRTC queue:
// Internal SDK code (not for app developers)
DispatchQueue.liveKitWebRTC.sync {
    // WebRTC operations here
}
App developers should not interact with WebRTC objects directly. Use the SDK’s public API instead.

Best Practices

  • Always await SDK methods
  • Use async let for parallel operations
  • Handle errors with do-catch
  • Check Task.isCancelled in long operations
  • Don’t access SDK internals directly
  • Use SDK’s public API for state access
  • Dispatch UI updates to main thread
  • Make delegates Sendable or @MainActor
  • Understand that SDK uses actors internally
  • Await actor method calls
  • Consider using actors in your own code
  • Don’t try to synchronously access actor state
  • Run expensive operations on background threads
  • Use Task.detached for truly independent work
  • Profile with Instruments to identify bottlenecks
  • Avoid blocking the main thread

SwiftUI Example

Complete SwiftUI example with proper concurrency:
import SwiftUI
import LiveKit

@MainActor
class RoomViewModel: ObservableObject {
    @Published var room = Room()
    @Published var connectionState: ConnectionState = .disconnected
    @Published var participants: [RemoteParticipant] = []
    
    init() {
        room.add(delegate: self)
    }
    
    func connect() async {
        do {
            try await room.connect(url: wsURL, token: token)
            try await room.localParticipant.setCamera(enabled: true)
        } catch {
            print("Failed to connect: \(error)")
        }
    }
    
    func disconnect() async {
        await room.disconnect()
    }
}

extension RoomViewModel: RoomDelegate {
    nonisolated func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState) {
        Task { @MainActor in
            self.connectionState = connectionState
        }
    }
    
    nonisolated func room(_ room: Room, participantDidJoin participant: RemoteParticipant) {
        Task { @MainActor in
            self.participants = Array(room.remoteParticipants.values)
        }
    }
}

struct RoomView: View {
    @StateObject private var viewModel = RoomViewModel()
    
    var body: some View {
        VStack {
            Text("State: \(viewModel.connectionState)")
            
            Button("Connect") {
                Task {
                    await viewModel.connect()
                }
            }
            
            Button("Disconnect") {
                Task {
                    await viewModel.disconnect()
                }
            }
        }
    }
}

See Also

  • Swift Concurrency documentation
  • AGENTS.md in source repository
  • Support/Async/ directory in SDK
  • Support/Schedulers/ directory in SDK

Build docs developers (and LLMs) love