How Bitchat Queues Messages Offline When Bluetooth and Nostr Are Unavailable
When neither Bluetooth Mesh nor Nostr relays are reachable, Bitchat stores private messages in a durable MessageOutboxStore and attempts courier delegation, automatically flushing the queue when connectivity returns.
Bitchat is a decentralized messaging app from permissionlesstech/bitchat that operates without centralized servers, relying instead on Bluetooth Mesh and Nostr protocols for peer-to-peer communication. When both transport layers fail to reach a recipient, the app implements a robust offline message queue mechanism that ensures messages survive temporary network partitions. This store-and-forward system persists messages to disk and leverages opportunistic couriers to maximize delivery probability even in complete network isolation.
Detecting Transport Failure in MessageRouter
The routing logic resides in MessageRouter.swift, specifically within the sendPrivate(_:to:recipientNickname:messageID:) method. Before attempting delivery, the router evaluates three conditions in strict order:
- A connected transport with an active secure session
- A connected transport without a secure session
- Any reachable transport (regardless of connection state)
If all checks fail—meaning no Bluetooth peer is in range and no Nostr relay is accessible—the execution hits the else branch at lines 55-61. At this point, the recipient is marked as offline and the message enters the queuing workflow rather than being dropped.
Creating and Enqueuing Offline Messages
When the router determines a peer is unreachable, it constructs a QueuedMessage struct (lines 93-99) with sendAttempts initialized to 0. This lightweight container stores the encrypted content, recipient nickname, message ID, and timestamp.
The enqueue(_:for:) method (lines 74-82) manages the in-memory queue:
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
var queue = outbox[peerID] ?? []
// Deduplicate: merge courier keys if message ID already exists
if let existing = queue.firstIndex(where: { $0.messageID == message.messageID }) {
message.depositedCourierKeys.formUnion(queue[existing].depositedCourierKeys)
queue.remove(at: existing)
}
queue.append(message)
// Enforce per-peer FIFO limit to prevent memory exhaustion
if queue.count > Self.maxMessagesPerPeer {
let evicted = queue.removeFirst()
dropMessage(evicted.messageID, for: peerID)
}
outbox[peerID] = queue
metrics?.record(.outboxQueued)
persistOutbox() // Critical: ensure durability
}
The outbox dictionary maintains a mapping of [PeerID: [QueuedMessage]], allowing the router to isolate queues per recipient. The method also respects a configurable maxMessagesPerPeer limit, evicting the oldest messages via FIFO to prevent unbounded memory growth.
Persisting the Outbox to Disk
To survive app termination, crashes, or device reboots, Bitchat integrates MessageOutboxStore (defined in MessageOutboxStore.swift). This protocol abstracts the persistence layer, allowing the router to call persistOutbox() after every enqueue operation.
The implementation at lines 17-20 delegates to the injected store:
private func persistOutbox() {
outboxStore?.save(outbox) // Serializes [PeerID: [QueuedMessage]] to disk
}
This guarantees that queued messages are never held purely in memory. When the app restarts, the router reinflates the outbox dictionary from disk, preserving the exact state of pending deliveries including retry counts and courier delegation status.
Opportunistic Courier Delegation
Even when direct transports are unavailable, Bitchat attempts to offload messages to physical couriers—intermediate peers that may physically transport the encrypted payload to the destination. Immediately after enqueuing, the router invokes attemptCourierDeposit(messageID:for:) at line 60.
This method performs three operations:
- Lookup: Retrieves the recipient's static Noise key from
courierDirectory.noiseKeyfor envelope sealing - Bridge Deposit: Calls
requestBridgeCourierDepositto optionally drop the message on a Nostr bridge if bridge mode is enabled - Peer Courier Selection: Filters
eligibleCouriersfrom connected transports and invokessendCourierMessageto hand off the sealed envelope
If a courier accepts the message, the router records the deposit event and fires onMessageCarried (lines 84-97), updating the depositedCourierKeys set within the QueuedMessage to prevent duplicate hand-offs during subsequent flush attempts.
Flushing the Queue When Connectivity Returns
The final stage of the offline message queue lifecycle occurs when a previously unreachable peer becomes available. The flushOutbox(for:) method (starting at line 94) iterates over all pending messages for that specific PeerID:
func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
for message in queued {
// Retry with best available transport
if let transport = connectedTransport(for: peerID),
transport.canDeliverSecurely(to: peerID) {
transport.sendPrivateMessage(
message.content,
to: peerID,
recipientNickname: message.nickname,
messageID: message.messageID
)
// Increment sendAttempts, update metrics, remove from queue on success
}
}
persistOutbox() // Sync state after flush attempt
}
This method respects transport priority, attempting Bluetooth Mesh first for direct peer-to-peer delivery, then falling back to Nostr if the Mesh connection is unstable. Successfully delivered messages are removed from the outbox dictionary and the updated state is persisted to disk.
Summary
- Detection:
MessageRouterevaluates transport reachability before every send; failure triggers the offline queue path insendPrivate. - Durability: Messages are wrapped in
QueuedMessagestructs and persisted viaMessageOutboxStore, surviving app restarts and crashes. - Limits: A per-peer FIFO cap prevents queue bloat, automatically evicting oldest messages when limits are exceeded.
- Couriers: Even without direct connectivity, the router attempts to deposit sealed envelopes with physical couriers or Nostr bridges.
- Eventual Delivery:
flushOutboxautomatically retries delivery when Bluetooth or Nostr transports reconnect, completing the store-and-forward cycle.
Frequently Asked Questions
What happens to queued messages if the app is force-quit?
Queued messages persist to disk via MessageOutboxStore.save(outbox) immediately upon enqueueing. When the app relaunches, the router reloads the outbox from disk during initialization, ensuring no messages are lost due to force-quits, crashes, or device reboots.
How does Bitchat prevent the offline queue from growing infinitely?
The enqueue(_:for:) method enforces Self.maxMessagesPerPeer, a FIFO limit that evicts the oldest message when the queue exceeds the threshold. This prevents memory exhaustion and disk bloat while prioritizing recent communications.
Can queued messages be delivered via third-party couriers?
Yes. Even when no direct transport is available, attemptCourierDeposit attempts to hand the sealed envelope to any connected peer acting as a courier. The router also supports bridge deposits to Nostr relays that may store-and-forward the message on behalf of offline recipients.
What triggers the flushOutbox method to retry delivery?
flushOutbox(for:) is invoked automatically when a transport signals that a peer has become reachable—typically when Bluetooth Mesh detects a peer in range or when a Nostr relay connection is established. The router monitors transport state changes and opportunistically retries all pending messages for newly reachable peers.
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 →