-
-
Notifications
You must be signed in to change notification settings - Fork 226
Description
Enhancement
To have a reliable Realtime experience, the client should handle reestablishing connection and resubscribing to channels when it detects that the app moved back to the foreground state.
Problem
When iOS/macOS apps move to the background, WebSocket connections are suspended or terminated by the OS. Currently, the Swift SDK has no built-in mechanism to:
- Detect when the app moves to background/foreground
- Gracefully disconnect when backgrounded (to release resources)
- Automatically reconnect and rejoin channels when foregrounded
Users must manually implement this logic, leading to inconsistent behavior and poor developer experience.
Root Cause
The RealtimeClientV2 has robust reconnection logic for network failures, but lacks integration with platform lifecycle APIs:
Sources/Realtime/RealtimeClientV2.swift:184-248- Connection flow exists but requires manual invocationSources/Realtime/RealtimeClientV2.swift:291-301- Reconnection logic exists but only triggers on WebSocket errorsSources/Realtime/RealtimeClientV2.swift:397-403- Channel rejoin logic works after reconnection
No platform lifecycle observers exist anywhere in the codebase.
Reference Implementation
The Flutter SDK provides a working pattern in packages/supabase_flutter/lib/src/supabase.dart:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.resumed:
onResumed(); // Reconnect and rejoin channels
case AppLifecycleState.detached:
case AppLifecycleState.paused:
_realtimeReconnectOperation?.cancel();
Supabase.instance.client.realtime.disconnect();
default:
}
}The JS SDK (SDK-277) is following a similar pattern with an exposed setAppStateActive() method.
Proposed Solution
Approach A: Automatic Lifecycle Handling (Recommended)
Add platform-specific lifecycle observers that automatically manage the Realtime connection.
Implementation Plan
1. Create RealtimeLifecycleManager class
New file: Sources/Realtime/RealtimeLifecycleManager.swift
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
@MainActor
final class RealtimeLifecycleManager: Sendable {
private weak var client: RealtimeClientV2?
private var reconnectTask: Task<Void, Never>?
init(client: RealtimeClientV2) {
self.client = client
setupObservers()
}
private func setupObservers() {
#if canImport(UIKit)
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
#elseif canImport(AppKit)
// macOS equivalent using NSApplication notifications
#endif
}
@objc private func appDidEnterBackground() {
reconnectTask?.cancel()
Task { await client?.disconnect() }
}
@objc private func appWillEnterForeground() {
reconnectTask = Task {
await client?.connect()
// Channels auto-rejoin via existing logic in RealtimeClientV2:397-403
}
}
}2. Add configuration option
In Sources/Realtime/Types.swift, add to RealtimeClientOptions:
/// Whether to automatically handle app lifecycle changes (background/foreground).
/// When enabled, the client will disconnect when the app enters background
/// and reconnect when it returns to foreground.
/// Default: true on iOS/macOS, false on Linux/other platforms.
public var handleAppLifecycle: Bool = defaultHandleAppLifecycle
#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) || os(visionOS)
private static let defaultHandleAppLifecycle = true
#else
private static let defaultHandleAppLifecycle = false
#endif3. Initialize lifecycle manager in RealtimeClientV2
In Sources/Realtime/RealtimeClientV2.swift, add to MutableState:
var lifecycleManager: RealtimeLifecycleManager?And in the initializer, after other setup:
#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) || os(visionOS)
if options.handleAppLifecycle {
Task { @MainActor in
mutableState.withValue {
$0.lifecycleManager = RealtimeLifecycleManager(client: self)
}
}
}
#endifApproach B: Manual Method (Alternative/Additional)
Expose a method for developers who want manual control, similar to JS SDK approach:
public extension RealtimeClientV2 {
/// Manually notify the client of app state changes.
/// Use this when `handleAppLifecycle` is disabled or for custom lifecycle handling.
/// - Parameter isActive: Whether the app is now active/foreground.
func setAppStateActive(_ isActive: Bool) async {
if isActive {
await connect()
} else {
mutableState.withValue { $0.reconnectTask?.cancel() }
await disconnect()
}
}
}Files to Change
-
Sources/Realtime/RealtimeLifecycleManager.swift- NEW - Platform lifecycle observer -
Sources/Realtime/Types.swift:16-67- AddhandleAppLifecycleoption -
Sources/Realtime/RealtimeClientV2.swift:34-51- Add lifecycle manager to MutableState -
Sources/Realtime/RealtimeClientV2.swift:120-168- Initialize lifecycle manager -
Sources/Realtime/RealtimeClientV2.swift- AddsetAppStateActive()method -
Tests/RealtimeTests/- Add unit tests for lifecycle handling
Implementation Notes
- Platform availability: Use
#if canImport(UIKit)/#if canImport(AppKit)for platform-specific code - Thread safety: Lifecycle callbacks come on main thread; use
Task { @MainActor }for setup - Cancellation: Must cancel pending reconnect tasks when backgrounding again
- Channel state: Existing auto-rejoin logic at
RealtimeClientV2.swift:397-403handles channel resubscription - Opt-out: Provide configuration option for users who want manual control
- Memory management: Use weak reference to client to avoid retain cycles
Edge Cases
- App backgrounded during reconnection attempt → Cancel and don't retry
- Multiple rapid background/foreground cycles → Debounce or cancel previous operations
- Connection already disconnected when foregrounding → Safe to call connect() again
- User manually disconnected → Should not auto-reconnect (add flag to track intent)
Acceptance Criteria
- Realtime connection automatically disconnects when app enters background
- Realtime connection automatically reconnects when app returns to foreground
- Subscribed channels automatically rejoin after foreground reconnection
- Behavior can be disabled via
RealtimeClientOptions.handleAppLifecycle = false - Manual
setAppStateActive()method available for custom handling - Works on iOS, macOS, tvOS, watchOS, and visionOS
- No-op on Linux/unsupported platforms (graceful degradation)
- Unit tests cover lifecycle transitions
- No memory leaks (weak references, proper cleanup)
- Documentation updated with usage examples
Testing Strategy
Unit Tests
func testAppBackgroundDisconnects() async {
let client = RealtimeClientV2(...)
await client.connect()
XCTAssertEqual(client.status, .connected)
// Simulate background notification
NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil)
// Allow async work to complete
await Task.yield()
XCTAssertEqual(client.status, .disconnected)
}
func testAppForegroundReconnects() async {
let client = RealtimeClientV2(...)
await client.connect()
// Simulate background then foreground
NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil)
await Task.yield()
NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil)
await Task.yield()
XCTAssertEqual(client.status, .connected)
}Manual Testing
- Connect to Realtime channel in iOS app
- Background the app (press home button)
- Wait 5+ seconds
- Return to foreground
- Verify connection restored and channel events continue
Research
Related Issues
- GitHub: #595
- Linear: SDK-277 (JS SDK equivalent - similar pattern)
- Linear: SDK-207 (Background downloads - different scope)
Reference Implementations
- Flutter:
packages/supabase_flutter/lib/src/supabase.dart- UsesWidgetsBindingObserver.didChangeAppLifecycleState - JS: SDK-277 proposes
setAppStateActive()method with AppState type
Existing Reconnection Logic
The Swift SDK already has robust reconnection:
RealtimeClientV2.swift:291-301-reconnect()methodRealtimeClientV2.swift:397-403- Auto-rejoins channels after reconnectionRealtimeChannelV2.swift:134-193- Retry logic with exponential backoff
The lifecycle manager just needs to trigger this existing logic at the right times.
Generated with Claude Code /issue