Skip to content

[Realtime] auto-reconnect socket and channels when internet connection is reestablished #579

@grdsdev

Description

@grdsdev

Enhancement

To have a reliable Realtime experience, the client should handle connection issues by reconnecting to the socket and resubscribing to channels when it detects that the internet connection has been reestablished.

Use NWPathMonitor on Apple platforms.


Problem

The current Realtime implementation uses passive detection of network issues - it only discovers connectivity loss when WebSocket operations fail or heartbeats timeout (up to 25 seconds delay). This results in:

  1. Delayed detection: Up to 25 seconds before heartbeat timeout triggers reconnection
  2. No proactive reconnection: When network is restored, the SDK waits for the next scheduled operation to fail/succeed
  3. Poor user experience: Users may think the app is stuck when network returns but connection hasn't re-established

Current Behavior

Event Detection Method Delay
Network lost WebSocket error or heartbeat timeout 0-25 seconds
Network restored Next reconnection attempt succeeds 7+ seconds (reconnect delay)

Desired Behavior

Event Detection Method Delay
Network lost NWPathMonitor callback Immediate
Network restored NWPathMonitor callback Immediate reconnect

Root Cause

The SDK has no active network monitoring. All network-related code is reactive:

  • Sources/Realtime/WebSocket/URLSessionWebSocket.swift:214-231 - Maps network errors to WebSocket close codes (reactive)
  • Sources/Realtime/RealtimeClientV2.swift:291-301 - Reconnection only triggers on close/error events
  • Sources/Realtime/RealtimeClientV2.swift:446-506 - Heartbeat provides health checks but with 25-second intervals

No NWPathMonitor or Network.framework usage exists anywhere in the codebase.

Proposed Solution

Approach: Implement NetworkMonitor using NWPathMonitor

Create a new component that monitors network connectivity and triggers immediate reconnection when connectivity is restored.

Implementation Plan

1. Create NetworkMonitor class

New file: Sources/Realtime/NetworkMonitor.swift

#if canImport(Network)
import Network

@available(iOS 12.0, macOS 10.14, tvOS 12.0, watchOS 5.0, *)
final class NetworkMonitor: Sendable {
  enum Status: Sendable {
    case satisfied      // Network available
    case unsatisfied    // No network
    case requiresConnection  // Network requires user action (captive portal)
  }
  
  private let monitor: NWPathMonitor
  private let queue = DispatchQueue(label: "com.supabase.realtime.networkmonitor")
  private let statusSubject = AsyncValueSubject<Status>(.unsatisfied)
  
  var status: Status { statusSubject.value }
  var statusChange: AsyncStream<Status> { statusSubject.values }
  
  init(requiredInterfaceType: NWInterface.InterfaceType? = nil) {
    if let interfaceType = requiredInterfaceType {
      monitor = NWPathMonitor(requiredInterfaceType: interfaceType)
    } else {
      monitor = NWPathMonitor()
    }
    
    monitor.pathUpdateHandler = { [weak self] path in
      let newStatus: Status
      switch path.status {
      case .satisfied:
        newStatus = .satisfied
      case .unsatisfied:
        newStatus = .unsatisfied
      case .requiresConnection:
        newStatus = .requiresConnection
      @unknown default:
        newStatus = .unsatisfied
      }
      self?.statusSubject.yield(newStatus)
    }
  }
  
  func start() {
    monitor.start(queue: queue)
  }
  
  func cancel() {
    monitor.cancel()
  }
}
#endif

2. Add configuration option

In Sources/Realtime/Types.swift, add to RealtimeClientOptions:

/// Whether to monitor network connectivity and trigger immediate reconnection
/// when connectivity is restored. Uses NWPathMonitor on Apple platforms.
/// Default: true on supported Apple platforms, false elsewhere.
public var monitorNetworkConnectivity: Bool = defaultMonitorNetworkConnectivity

#if os(iOS) || os(macOS) || os(tvOS) || os(watchOS) || os(visionOS)
private static let defaultMonitorNetworkConnectivity = true
#else
private static let defaultMonitorNetworkConnectivity = false
#endif

3. Integrate into RealtimeClientV2

In Sources/Realtime/RealtimeClientV2.swift:

// Add to MutableState
#if canImport(Network)
@available(iOS 12.0, macOS 10.14, tvOS 12.0, watchOS 5.0, *)
var networkMonitor: NetworkMonitor?
var networkMonitorTask: Task<Void, Never>?
#endif

// Add to initializer (after other setup)
#if canImport(Network)
if #available(iOS 12.0, macOS 10.14, tvOS 12.0, watchOS 5.0, *),
   options.monitorNetworkConnectivity {
  setupNetworkMonitoring()
}
#endif

// New method
#if canImport(Network)
@available(iOS 12.0, macOS 10.14, tvOS 12.0, watchOS 5.0, *)
private func setupNetworkMonitoring() {
  let monitor = NetworkMonitor()
  
  mutableState.withValue {
    $0.networkMonitor = monitor
    $0.networkMonitorTask = Task { [weak self] in
      var previousStatus: NetworkMonitor.Status?
      
      for await status in monitor.statusChange {
        guard let self else { return }
        
        // Trigger reconnection when network transitions to satisfied
        if status == .satisfied && previousStatus != .satisfied {
          // Only reconnect if we were disconnected
          if self.status == .disconnected {
            await self.connect()
          }
        }
        
        previousStatus = status
      }
    }
  }
  
  monitor.start()
}
#endif

// Add cleanup to deinit/disconnect
#if canImport(Network)
if #available(iOS 12.0, macOS 10.14, tvOS 12.0, watchOS 5.0, *) {
  mutableState.withValue {
    $0.networkMonitorTask?.cancel()
    $0.networkMonitor?.cancel()
  }
}
#endif

4. Expose network status to apps

Add public API for apps that want to observe network status:

/// Current network connectivity status.
/// Available on iOS 12+, macOS 10.14+, tvOS 12+, watchOS 5+.
#if canImport(Network)
@available(iOS 12.0, macOS 10.14, tvOS 12.0, watchOS 5.0, *)
public var networkStatus: NetworkMonitor.Status? {
  mutableState.networkMonitor?.status
}

@available(iOS 12.0, macOS 10.14, tvOS 12.0, watchOS 5.0, *)
public var networkStatusChange: AsyncStream<NetworkMonitor.Status>? {
  mutableState.networkMonitor?.statusChange
}
#endif

Files to Change

  • Sources/Realtime/NetworkMonitor.swift - NEW - NWPathMonitor wrapper
  • Sources/Realtime/Types.swift:15-67 - Add monitorNetworkConnectivity option
  • Sources/Realtime/RealtimeClientV2.swift:34-51 - Add network monitor to MutableState
  • Sources/Realtime/RealtimeClientV2.swift:120-168 - Initialize and start network monitoring
  • Sources/Realtime/RealtimeClientV2.swift:512-533 - Cleanup in disconnect/deinit
  • Tests/RealtimeTests/ - Add tests for network monitoring

Implementation Notes

  1. Platform availability: NWPathMonitor requires iOS 12+, macOS 10.14+, tvOS 12+, watchOS 5+ - use @available and #if canImport(Network)
  2. Thread safety: NWPathMonitor callbacks come on custom dispatch queue; use async/await bridge
  3. Debouncing: Network status can fluctuate rapidly - may need debouncing to avoid reconnect storms
  4. Integration with SDK-652: This complements background/foreground handling - both can work together
  5. Captive portals: Handle .requiresConnection status appropriately (don't attempt reconnect)

Edge Cases

  • Network fluctuates rapidly (wifi to cellular transitions) - debounce reconnection attempts
  • Captive portal detected - don't attempt reconnection until resolved
  • Already connecting when network status changes - don't double-connect
  • Monitor started but connection intentionally disconnected by user - respect user intent
  • VPN connects/disconnects - should trigger path update

Relationship to SDK-652

Issue Trigger Use Case
SDK-652 App lifecycle (background/foreground) iOS suspends WebSocket when backgrounded
SDK-653 (this) Network connectivity change Airplane mode, wifi loss, cellular handoff

Both issues can share infrastructure (e.g., RealtimeLifecycleManager) but address different scenarios.

Acceptance Criteria

  • NWPathMonitor used on supported Apple platforms to detect network changes
  • Immediate reconnection triggered when network transitions from unavailable to available
  • No reconnection attempt when network remains unavailable
  • Respects .requiresConnection status (captive portals) - does not attempt blind reconnect
  • Behavior can be disabled via RealtimeClientOptions.monitorNetworkConnectivity = false
  • Network status exposed via public API for app-level UI updates
  • Works on iOS 12+, macOS 10.14+, tvOS 12+, watchOS 5+, visionOS 1+
  • Graceful degradation on Linux/unsupported platforms (no-op)
  • Unit tests cover network status transitions
  • No memory leaks (proper cleanup of NWPathMonitor)
  • Channels automatically rejoin after network-triggered reconnection (existing behavior)

Testing Strategy

Unit Tests

func testNetworkRestoredTriggersReconnect() async {
  let client = RealtimeClientV2(...)
  await client.connect()
  
  // Simulate network loss (would need mock NetworkMonitor)
  // ...
  
  // Simulate network restored
  // ...
  
  // Verify reconnection was triggered
  XCTAssertEqual(client.status, .connected)
}

func testNoReconnectOnCaptivePortal() async {
  // When network status is .requiresConnection
  // Should NOT attempt automatic reconnection
}

Manual Testing

  1. Connect to Realtime channel
  2. Enable Airplane mode
  3. Verify connection status changes to disconnected
  4. Disable Airplane mode
  5. Verify immediate reconnection (not waiting for heartbeat timeout)
  6. Verify channel events resume

Network Condition Simulator

Use Xcode's Network Link Conditioner or networksetup CLI to simulate:

  • 100% packet loss (network unavailable)
  • Restore to normal (network available)
  • Verify reconnection timing is immediate vs. ~25 seconds without this feature

Research

Related Issues

  • GitHub: #579
  • Linear: SDK-652 (Background/foreground handling - complementary)
  • Linear: SDK-277 (JS SDK app state handling)

Apple Documentation

Existing Reconnection Infrastructure

The SDK already has robust reconnection mechanics:

  • RealtimeClientV2.swift:291-301 - reconnect() method
  • RealtimeClientV2.swift:397-403 - Channel auto-rejoin
  • RealtimeClientV2.swift:589-626 - Send buffer mechanism

This enhancement just needs to call the existing connect() at the right time.

Current Network Error Handling

Already maps these URLSession errors to WebSocket close codes (URLSessionWebSocket.swift:214-231):

  • NSURLErrorTimedOut -> 1006
  • NSURLErrorNetworkConnectionLost -> 1006
  • NSURLErrorNotConnectedToInternet -> 1006

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