How BitChat's Outbox Handles Message Retries: Swift Implementation Guide

BitChat implements a persistent in‑memory outbox using PendingSend structs and exponential backoff to guarantee Nostr message delivery across unstable relay connections.

BitChat, the decentralized messaging app from the permissionlesstech/bitchat repository, guarantees reliable message delivery through a sophisticated outbox mechanism. When network conditions fluctuate or relays temporarily disconnect, the app stores outbound events in a retry queue rather than dropping them. This article examines the Swift implementation in NostrRelayManager.swift to explain exactly how the outbox manages message persistence, reconnection logic, and overflow protection.

Core Outbox Data Structures

The retry system centers on two primary structures defined in bitchat/Nostr/NostrRelayManager.swift:

  • messageQueue: [PendingSend] – An array that stores every outbound event awaiting acknowledgment. Each PendingSend instance encapsulates the NostrEvent and a Set of relays that have not yet accepted the message.
  • ConfirmedSendState – A tracking mechanism for messages requiring explicit relay acknowledgments (such as gift‑wrapped private envelopes). This state monitors which relays must return an OK response before the message clears the outbox.

When send(event:) is invoked, the manager creates a PendingSend entry and appends it to messageQueue (lines 63‑70). The message remains queued until every target relay either acknowledges receipt or the queue exceeds its memory limits.

The Message Retry Workflow

BitChat’s outbox operates on a "store‑and‑forward" principle with four distinct phases:

  1. Queue insertion – The event wraps into a PendingSend along with its target relay set, then pushes onto messageQueue.

  2. Flush on reconnection – The flushMessageQueue() method iterates over cached entries whenever a relay connection becomes viable. For each PendingSend, it attempts connection.send(message) on every relay still listed in pendingRelays.

  3. Error handling – If a WebSocket send fails, the relay remains in the pendingRelays set. The entry stays in messageQueue for automatic retry during the next connection window.

  4. Acknowledgment verification – For gift‑wrapped messages, ConfirmedSendState tracks pending OK responses. The outbox removes the entry only after every targeted relay replies; otherwise, retries continue indefinitely.

Exponential Backoff and Jitter

To prevent thundering herds against struggling relays, BitChat applies calculated delays before reconnection attempts. The backoff configuration (lines 87‑90) uses three parameters:

  • initialBackoffInterval – Base delay for the first retry attempt.
  • maxBackoffInterval – Upper bound for the delay ceiling.
  • backoffMultiplier – Factor by which the interval increases after each failed attempt.

The manager adds jitterUnit to these calculations, introducing randomization that desynchronizes retry storms across multiple relay connections. When scheduleAfter(_:) triggers a reconnection, the exponential backoff ensures the outbox retries messages with a geometrically increasing delay, respecting both network stability and relay load.

Queue Management and Overflow Protection

Unbounded memory growth is mitigated through pendingSendDropCount. When messageQueue exceeds its configured capacity, the manager discards the oldest PendingSend entries and increments the drop counter. This circuit breaker triggers a UI warning, alerting users that some messages may require manual resend while preventing application memory exhaustion.

Code Implementation Example

Below is a practical demonstration of how BitChat’s outbox handles a private message and recovers from transient failures:

import Foundation

// 1. Sending a gift-wrapped private message
let encryptedPayload = sealMessage(content: "Hello", to: recipientPubkey)
let event = NostrEvent(kind: .privateMessage, content: encryptedPayload)

NostrRelayManager.shared.send(event: event) { success in
    // This callback fires only after every default relay 
    // responds with an OK acknowledgment
    if success {
        print("Message confirmed by all relays")
    } else {
        print("Message remains in outbox for retry")
    }
}

// 2. Internal queue management (simplified view)
// Located in bitchat/Nostr/NostrRelayManager.swift
class NostrRelayManager {
    private var messageQueue: [PendingSend] = []
    private var confirmedSends: [String: ConfirmedSendState] = [:]
    
    func send(event: NostrEvent) {
        let pending = PendingSend(
            event: event,
            pendingRelays: Set(defaultRelays)
        )
        messageQueue.append(pending)
        flushMessageQueue()
    }
    
    func flushMessageQueue() {
        for (index, pending) in messageQueue.enumerated().reversed() {
            for relay in pending.pendingRelays {
                if let connection = activeConnections[relay] {
                    do {
                        try connection.send(pending.event)
                        // Remove relay from pending set on successful socket write
                        messageQueue[index].pendingRelays.remove(relay)
                    } catch {
                        // Relay stays in pendingRelays; retry on next flush
                    }
                }
            }
            
            // Cleanup if all relays processed
            if messageQueue[index].pendingRelays.isEmpty {
                messageQueue.remove(at: index)
            }
        }
    }
}

Summary

  • Persistent Queue – The messageQueue array stores PendingSend objects that bind each event to its remaining target relays.
  • Automatic Retry – Failed sends retain the relay in pendingRelays, triggering automatic resend during the next flushMessageQueue() cycle.
  • Backoff Strategy – Exponential backoff with randomized jitter (configured in lines 87‑90) spaces reconnection attempts to avoid overwhelming relays.
  • Memory Safety – pendingSendDropCount enforces a ceiling on queue length, dropping oldest entries and warning users when resources are constrained.
  • Acknowledgment Tracking – ConfirmedSendState ensures gift‑wrapped messages receive explicit relay confirmation before clearing the outbox.

Frequently Asked Questions

How does BitChat prevent message loss during network outages?

BitChat persists unsent messages in the messageQueue array within NostrRelayManager. Rather than discarding failed transmissions, the outbox retains the event alongside its pendingRelays set. When connectivity resumes, flushMessageQueue() automatically retries delivery to every relay that has not yet acknowledged the message.

What triggers a message retry in the BitChat outbox?

Retries trigger in two scenarios: when flushMessageQueue() runs after a successful relay reconnection (scheduled via exponential backoff), or when the user sends a new event that prompts the manager to re‑evaluate pending items. The scheduleAfter(_:) method manages these reconnection windows using jittered delays defined in lines 87‑90 of NostrRelayManager.swift.

Does BitChat implement exponential backoff for relay connections?

Yes. The NostrRelayManager configures backoff parameters including initialBackoffInterval, maxBackoffInterval, and backoffMultiplier. Failed connections trigger scheduleAfter(_:) with geometrically increasing delays, plus a jitterUnit randomization factor to prevent synchronized retry storms across multiple relay clients.

What happens when the BitChat message queue grows too large?

When messageQueue exceeds its memory threshold, the manager increments pendingSendDropCount and removes the oldest PendingSend entries. This protects against unbounded memory growth while surfacing a UI warning that notifies users certain messages may need manual resending.

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 →