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. EachPendingSendinstance encapsulates theNostrEventand aSetof 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 anOKresponse 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:
-
Queue insertion – The event wraps into a
PendingSendalong with its target relay set, then pushes ontomessageQueue. -
Flush on reconnection – The
flushMessageQueue()method iterates over cached entries whenever a relay connection becomes viable. For eachPendingSend, it attemptsconnection.send(message)on every relay still listed inpendingRelays. -
Error handling – If a WebSocket send fails, the relay remains in the
pendingRelaysset. The entry stays inmessageQueuefor automatic retry during the next connection window. -
Acknowledgment verification – For gift‑wrapped messages,
ConfirmedSendStatetracks pendingOKresponses. 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
messageQueuearray storesPendingSendobjects that bind each event to its remaining target relays. - Automatic Retry – Failed sends retain the relay in
pendingRelays, triggering automatic resend during the nextflushMessageQueue()cycle. - Backoff Strategy – Exponential backoff with randomized jitter (configured in lines 87‑90) spaces reconnection attempts to avoid overwhelming relays.
- Memory Safety –
pendingSendDropCountenforces a ceiling on queue length, dropping oldest entries and warning users when resources are constrained. - Acknowledgment Tracking –
ConfirmedSendStateensures 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →