How BitChat Handles Offline Private Messages: Store-and-Forward System Explained
BitChat guarantees delivery of offline private messages using a store-and-forward pipeline that retains encrypted copies locally, hands sealed envelopes to trusted courier peers, and falls back to internet bridges when mesh delivery fails.
BitChat's peer-to-peer architecture requires robust handling of offline private messages when recipients are unreachable via direct connections. The permissionlesstech/bitchat repository implements a resilient store-and-forward system built around three core components: a single-writer conversation store, an intelligent message router with persistent outbox management, and cryptographically sealed courier envelopes that opaque payloads for physical transport.
Core Components of the Store-and-Forward Pipeline
The offline message system relies on three architectural layers working in concert to ensure eventual delivery without sacrificing privacy.
ConversationStore as the Source of Truth
The ConversationStore serves as the single-writer source of truth for all chat history. When a user opens a private chat, the PrivateChatManager mirrors the store's selection via selectedPeer, and all incoming or outgoing messages append to this canonical history (ConversationStore.append). This design ensures that even if the store-and-forward layer retries multiple times, the conversation state remains consistent and deduplicated at the storage layer.
MessageRouter and Persistent Outbox Management
The MessageRouter retains a copy of every outgoing private message and determines the optimal delivery path. Located in bitchat/Services/MessageRouter.swift, the router implements the critical sendPrivate(_:to:recipientNickname:messageID:) method.
When invoked, the router immediately creates a QueuedMessage and enqueues it:
func sendPrivate(_ content: String, to peerID: PeerID, …) {
let message = QueuedMessage(content: content, …)
enqueue(message, for: peerID) // Retain copy regardless of delivery success
// …transport selection logic…
attemptCourierDeposit(messageID: message.messageID, for: peerID)
}
The outbox persists to disk via outboxStore?.save(outbox), ensuring that offline private messages survive application restarts and device reboots.
CourierEnvelope for Opaque Transport
The CourierEnvelope (defined in localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift) creates an opaque container for physical couriers or internet bridges. Each envelope derives a unique recipient tag from the recipient's static Noise key:
CourierEnvelope.recipientTag(noiseStaticKey:epochDay:)
This tag allows couriers to route envelopes without decrypting payloads, while the envelope remains sealed with the sender's private key. The payload remains opaque to all intermediate hops, preserving end-to-end encryption even when messages traverse untrusted peer devices.
How Offline Message Delivery Works
When the recipient is offline, BitChat transitions from immediate delivery attempts to a store-and-forward strategy leveraging trusted couriers and optional bridge relays.
The Sending Process
The router follows a strict retention policy for every private message:
- Creates a queued copy containing content, nickname, ID, and timestamp
- Attempts immediate delivery via available transports (prioritizing connected and secure channels)
- Always retains the copy in the outbox via
enqueue(message, for: peerID), regardless of immediate delivery success
This enqueue-then-attempt pattern guarantees that no message is lost due to transient transport failures.
Courier Deposit When Recipients Are Offline
If no transport can deliver promptly (!transport.canDeliverPromptly(to:)), the router initiates courier deposit in bitchat/Services/MessageRouter.swift:
- Key Lookup: Retrieves the recipient's static Noise key via
courierDirectory.noiseKey(peerID)(offline favorites expose the full 64-hex key) - Courier Selection: Selects up to
maxCouriersPerMessage(3) trusted couriers from mutual favorites usingeligibleCouriers - Envelope Distribution: Calls
sendCourierMessagefor each selected courier, handing the sealedCourierEnvelopeto mesh transports
The router records deposited courier keys via recordCourierDeposit to prevent duplicate deliveries to the same courier:
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID),
let entry = queuedMessage(messageID, for: peerID) else { return }
// Bridge deposit (optional fallback)
requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey)
// Hand to up to 3 physical couriers
for transport in transports where transport is MeshCourierTransporting {
let couriers = eligibleCouriers(on: transport,
recipientKey: recipientKey,
excluding: entry.depositedCourierKeys,
limit: remainingSlots)
transport.sendCourierMessage(entry.content,
messageID: messageID,
recipientNoiseKey: recipientKey,
via: couriers.map(\.peerID))
}
}
Physical couriers store these envelopes locally and carry them until encountering the offline peer via BLE mesh. When the recipient reconnects, their device decrypts the envelope and transmits an acknowledgment back through the mesh, triggering cleanup on the sender's device.
Bridge Backup via Internet Relay
If configured, the router simultaneously calls bridgeCourierDeposit (a closure injected at bootstrap) to post the sealed envelope to a Nostr relay. This provides a secondary delivery path that functions independently of physical proximity or mesh connectivity. The bridge receives the identical opaque payload that physical couriers handle, maintaining cryptographic consistency across transport methods.
Message Retention, TTL, and Cleanup
BitChat implements strict resource limits to prevent unbounded storage growth while ensuring adequate time for eventual delivery.
Time-to-Live (TTL): Outbox entries expire after 24 hours (messageTTLSeconds = 24 × 60 × 60).
Storage Cap: Each peer's outbox is limited to 100 messages (maxMessagesPerPeer). When exceeded, the oldest message is evicted using an evict oldest message policy.
Acknowledgment Handling: Upon receiving delivery or read receipts, markDelivered removes the message from the outbox and persists a tombstone via outboxStore?.recordRemoval, preventing replay attacks while maintaining audit trails.
These mechanisms ensure that stale offline private messages do not consume device storage indefinitely, while active messages have sufficient time to reach recipients across intermittent connectivity patterns.
Handling Identity Changes and Message Consolidation
Peers may appear under different identifiers (temporary Nostr IDs versus stable Noise keys). The PrivateChatManager.consolidateMessages method (located in bitchat/Services/PrivateChatManager.swift) resolves this by:
- Detecting when a peer reconnects with a new ID but matching Noise key
- Pulling retained messages from the old identifier's outbox
- Updating the
senderPeerIDfield to the new canonical ID - Appending messages to the new conversation while preserving unread status
This consolidation ensures that offline private messages sent to temporary identifiers eventually arrive in the correct conversation thread when the recipient stabilizes their network identity.
Summary
- BitChat retains every private message in a persistent outbox via
MessageRouter.enqueue, ensuring survival across app restarts and device reboots. - Courier envelopes use recipient tags derived from Noise static keys, allowing opaque routing through untrusted intermediaries without decrypting payloads.
- Physical couriers receive sealed envelopes when immediate delivery fails, storing and forwarding messages via BLE mesh until encountering the offline recipient.
- Internet bridges provide parallel delivery paths through Nostr relays, functioning independently of mesh topology.
- Automatic cleanup occurs through 24-hour TTL expiration, 100-message per-peer caps, and acknowledgment-based tombstoning when recipients receive messages.
Frequently Asked Questions
How does BitChat ensure offline private messages aren't lost when the app closes?
The MessageRouter immediately persists every queued message to disk via outboxStore?.save(outbox) before attempting delivery. This write-ahead pattern ensures that offline private messages survive application termination, device reboots, and power cycles. When the app restarts, it reloads the outbox and resumes delivery attempts for any unacknowledged messages.
Can couriers read the private messages they carry?
No. Couriers handle CourierEnvelope containers that remain cryptographically sealed. The envelope derives a routing tag from the recipient's Noise static key for addressing purposes, but the payload contents encrypt with the recipient's public key. Only the intended recipient possesses the private key necessary to decrypt the message contents, maintaining end-to-end confidentiality even when stored on intermediary devices.
What happens if an offline recipient never comes back online?
Messages expire automatically after 24 hours (messageTTLSeconds = 86,400). Additionally, each peer's outbox maintains a hard limit of 100 messages (maxMessagesPerPeer), evicting the oldest entries when full. If the recipient remains offline beyond the TTL window, the sender's device permanently deletes the queued copy after recording a removal tombstone, freeing storage resources.
How does BitChat prevent duplicate delivery of the same offline message?
The MessageRouter tracks deposited courier keys within each message entry (entry.depositedCourierKeys). Before handing an envelope to a courier, the router checks this set via recordCourierDeposit and excludes already-used couriers from subsequent eligibleCouriers queries. Once the recipient acknowledges receipt through any path (courier or bridge), markDelivered removes the message from the outbox entirely, preventing any future delivery attempts.
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 →