How Bitchat Handles Network Partitions and Disconnections: A Technical Deep Dive

Bitchat detects and recovers from network partitions through three independent transport stacks—Nostr, BLE, and Geohash-based relays—each using Combine publishers for real-time state monitoring, thread-safe caching, and automatic retry scheduling with queued message persistence.

The bitchat decentralized messaging protocol must remain functional despite intermittent connectivity, Tor blocking, or Bluetooth mesh fragmentation. This article examines exactly how each transport layer in permissionlesstech/bitchat implements partition detection, state buffering, and seamless recovery.

NostrTransport: Detecting Relay Partitioning

How the DM Relay Connection Is Monitored

The NostrTransport class subscribes to NostrRelayManager.shared.$isDMRelayConnected, a Combine publisher that reports Tor-protected DM relay status. When this flips to false, the transport immediately knows the Internet path is severed.

From [bitchat/Services/NostrTransport.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/NostrTransport.swift), lines 62-66:

// The publisher that drives all Nostr connectivity state
relayConnectivityCancellable = NostrRelayManager.shared.$isDMRelayConnected
    .receive(on: queue)
    .sink { [weak self] connected in
        self?.relaysConnected = connected
        self?.reachablePeersDidChange()
    }

The relaysConnected flag is cached on a concurrent dispatch queue, allowing synchronous reads from the main actor while updates occur asynchronously.

Message Queuing During Outages

When relaysConnected becomes false, canDeliverPromptly short-circuits and queues outbound messages. The same cancellable (lines 162-166) ensures the instant the relay reconnects, pending traffic resumes without user intervention.

Retry throttling is governed by TransportConfig.uiGeoNotesConnectivityRetrySeconds, defaulting to 3 seconds.

BLEService: Mesh Partition Detection

Periodic Peer Reconciliation

Unlike Nostr's push-based model, BLEService actively polls peer health. Every ~30 seconds, checkPeerConnectivity() queries the underlying BLEEngine for linkStates—a dictionary mapping peer IDs to connection status.

From [bitchat/Services/BLE/BLEService.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/BLE/BLEService.swift), lines 7073-7094:

func reconcileConnectivity(now: Date, linkStates: [PeerID: BLELinkState]) {
    registry.pruneStalePeers(asOf: now, linkStates: linkStates)
    let changes = registry.computeConnectivityChanges()
    
    // Emit events that trigger UI updates and message re-broadcast
    blePeerConnectivitySubject.send(changes)
    
    // Re-advertise presence when mesh health improves
    if changes.newReachablePeers.isEmpty == false {
        schedulePresenceBroadcast()
    }
}

Any peer missing from linkStates or marked inactive is treated as disconnected. The BLEPeerRegistry maintains the source of truth, and stale peers are automatically re-advertised when the mesh recovers, triggering message re-transmission.

GeohashPresenceService: Geographic Relay Recovery

Unified Connectivity Handling

GeohashPresenceService reuses the same relayConnectivity publisher as NostrTransport, calling handleConnectivityChange() on every state transition.

From [bitchat/Services/GeohashPresenceService.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/GeohashPresenceService.swift), lines 184-208:

private func handleConnectivityChange(isConnected: Bool) {
    guard isConnected else {
        pendingGeohashUpdates.forEach { $0.markWaitingForNetwork() }
        return
    }
    
    // Connectivity restored—schedule batched retry
    scheduleConnectivityRetry(
        interval: TransportConfig.uiGeoNotesConnectivityRetrySeconds
    ) { [weak self] in
        self?.flushPendingUpdates()
    }
}

This ensures geohash-based location broadcasts survive temporary relay unavailability without duplicating effort or flooding the network.

UI Feedback: ConnectivityStatusBanner

Translating Low-Level State to User Action

The [ConnectivityStatusBanner.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Views/Components/ConnectivityStatusBanner.swift) component converts technical failures into actionable guidance:

enum ConnectivityIssue {
    case bluetoothPoweredOff
    case torBlocked
    case relayUnreachable
    
    static func resolve(
        bluetoothState: CBManagerState,
        torBlocked: Bool
    ) -> ConnectivityIssue? { ... }
}

The banner appears when any transport reports loss of connectivity and dismisses automatically when the respective flag recovers. This gives users immediate visibility while the protocol continues buffering outbound traffic transparently.

Key Architectural Patterns

Bitchat's partition handling rests on four foundational patterns:

  • Publish-Subscribe State Propagation — Combine publishers eliminate polling overhead and guarantee reactive, instant UI updates
  • Thread-Safe Caching — Serial/concurrent queues protect relaysConnected, reachablePeers, and BLEPeerRegistry state without blocking the main actor
  • Graceful Degradation with Queuing — Messages become QueuedAck objects in NostrTransport rather than failing; automatic retry resumes transmission post-recovery
  • Centralized Configuration — TransportConfig (uiGeoNotesConnectivityRetrySeconds, nostrReadAckInterval) provides uniform retry behavior across all transports

Practical Implementation Example

Observe and respond to connectivity changes in your own Bitchat integration:

import Combine
import Bitchat

class ResilientChatController: ObservableObject {
    @Published var issue: ConnectivityIssue?
    @Published var canSend = true
    
    private var cancellables = Set<AnyCancellable>()
    
    init(transport: NostrTransport) {
        // React to Nostr relay partitioning
        transport.$relaysConnected
            .map { $0 ? nil : .relayUnreachable }
            .assign(to: &$issue)
        
        // React to BLE mesh health
        BLEService.shared.peerConnectivityPublisher
            .map { changes in
                changes.disconnectedPeers.isEmpty ? nil : .localMeshDegraded
            }
            .assign(to: &$issue)
        
        // Drive send button state
        $issue
            .map { $0 == nil }
            .assign(to: &$canSend)
    }
}

Summary

  • Three independent transports (Nostr, BLE, Geohash) each monitor and recover from partitions autonomously
  • Combine publishers provide real-time connectivity state without polling
  • Thread-safe caches enable fast synchronous checks while maintaining background update safety
  • Automatic message queuing prevents data loss during outages; retries resume seamlessly
  • Centralized TransportConfig controls retry intervals uniformly
  • ConnectivityStatusBanner surfaces actionable feedback without interrupting protocol operation

Frequently Asked Questions

How does Bitchat prevent message loss during a network partition?

Outbound messages are converted to QueuedAck objects and held in memory. When relaysConnected or mesh connectivity restores, the retry scheduler automatically re-attempts delivery. No user action is required.

What triggers the connectivity status banner to appear?

The banner renders when ConnectivityIssue.resolve() detects CBManagerState.poweredOff, a Tor block flag, or relay unavailability from any active transport. It dismisses automatically once the underlying publisher reports restoration.

Why does BLEService use polling instead of Combine publishers?

Bluetooth Low Energy link state changes are not uniformly exposable as publisher streams across iOS versions. The checkPeerConnectivity() interval (~30s) balances timely detection with battery impact.

Can retry intervals be configured per-transport?

All transports read from TransportConfig. While there's a single source of truth, you can subclass or modify uiGeoNotesConnectivityRetrySeconds and nostrReadAckInterval to adjust behavior globally.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →