Skip to content

[Realtime] handle connection when app changes between background/foreground state #595

@grdsdev

Description

@grdsdev

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:

  1. Detect when the app moves to background/foreground
  2. Gracefully disconnect when backgrounded (to release resources)
  3. 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 invocation
  • Sources/Realtime/RealtimeClientV2.swift:291-301 - Reconnection logic exists but only triggers on WebSocket errors
  • Sources/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
#endif

3. 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)
    }
  }
}
#endif

Approach 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 - Add handleAppLifecycle option
  • Sources/Realtime/RealtimeClientV2.swift:34-51 - Add lifecycle manager to MutableState
  • Sources/Realtime/RealtimeClientV2.swift:120-168 - Initialize lifecycle manager
  • Sources/Realtime/RealtimeClientV2.swift - Add setAppStateActive() method
  • Tests/RealtimeTests/ - Add unit tests for lifecycle handling

Implementation Notes

  1. Platform availability: Use #if canImport(UIKit) / #if canImport(AppKit) for platform-specific code
  2. Thread safety: Lifecycle callbacks come on main thread; use Task { @MainActor } for setup
  3. Cancellation: Must cancel pending reconnect tasks when backgrounding again
  4. Channel state: Existing auto-rejoin logic at RealtimeClientV2.swift:397-403 handles channel resubscription
  5. Opt-out: Provide configuration option for users who want manual control
  6. 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

  1. Connect to Realtime channel in iOS app
  2. Background the app (press home button)
  3. Wait 5+ seconds
  4. Return to foreground
  5. 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 - Uses WidgetsBindingObserver.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() method
  • RealtimeClientV2.swift:397-403 - Auto-rejoins channels after reconnection
  • RealtimeChannelV2.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

Metadata

Metadata

Assignees

No one assigned

    Labels

    StaleenhancementNew feature or requestrealtimeWork related to realtime package

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions