What Is the Sender Outbox in BitChat and How Does It Work?

The Sender Outbox in BitChat is a durable, per-peer encrypted queue that stores outgoing direct messages until the recipient acknowledges receipt, guaranteeing exactly-once delivery even across app restarts and network failures.

BitChat is an open-source, peer-to-peer messaging application developed by permissionlesstech that prioritizes privacy and reliability. The Sender Outbox ensures that direct messages (DMs) are never lost when the recipient is offline or unreachable, persisting them to disk with strong encryption. This article examines the Swift implementation in MessageOutboxStore.swift and MessageRouter.swift to explain how the outbox manages, secures, and flushes outbound traffic.

Implementation Architecture

MessageOutboxStore (Persistent Storage Layer)

The foundation of the Sender Outbox is MessageOutboxStore, located at bitchat/Services/Courier/MessageOutboxStore.swift. This class maintains a persistent dictionary of queued messages indexed by the recipient’s peer-ID.

Key implementation details:

  • Encryption at rest: Each queued payload is encrypted with a symmetric key derived from the recipient’s fingerprint and a per-app secret stored in the iOS Keychain. This ensures that even if the device is compromised, the outbox remains unreadable without the Keychain material.
  • Atomic writes: The store writes the entire queue to disk atomically, preventing corruption if the app terminates mid-write.
  • In-memory recovery: On initialization, load() reads the persisted file and reconstructs the [PeerID: [QueuedMessage]] dictionary, allowing the app to resume sending operations immediately after relaunch.

MessageRouter (Coordination Layer)

Located at bitchat/Services/Courier/MessageRouter.swift, the router acts as the traffic controller between the UI and the outbox. It exposes high-level methods such as sendMessage(_:to:) and flushOutbox(for:), while internally managing transport selection (BLE, TCP, Nostr, etc.) and retry logic.

The router enforces delivery policies:

  • TTL expiry: Messages older than a configurable threshold are automatically dropped.
  • Attempt caps: A maximum retry count prevents infinite loops against unreachable peers.
  • ACK-driven removal: A message is only deleted from MessageOutboxStore after a valid acknowledgment (ACK) is received from the remote peer, ensuring exactly-once semantics.

Keychain Integration

The outbox relies on a Keychain protocol implementation passed into MessageOutboxStore's initializer. The store uses this to retrieve the master secret required to derive per-peer encryption keys. This separation of concerns keeps cryptographic material out of the queue management logic and leverages the Secure Enclave where available.

Lifecycle of an Outgoing Message

Understanding the flow of a DM through the Sender Outbox clarifies how reliability is achieved:

  1. Enqueue: When the user sends a DM, MessageRouter creates a QueuedMessage value, increments the revision counter, and calls outboxStore.save([peerID: [queued]]). The payload is encrypted and written to disk before the network request begins.
  2. Attempt: The router selects a transport and transmits the encrypted blob. The attemptCount field on the queued entry is incremented and persisted, allowing crash recovery to respect retry limits.
  3. ACK processing: Upon receiving an ACK packet from the peer, the router invokes flushOutbox(for: peerID). This method queries the store for all acknowledged entries and atomically removes them, freeing disk space.
  4. Retry / Failure: If the attempt cap or TTL is reached without an ACK, the router invokes the onMessageDropped callback (assigned by the UI layer) and deletes the entry from the store.
  5. App restart: On cold start, MessageOutboxStore.load() restores pending entries. The router immediately re-queues them for delivery, preserving the original order per recipient.

Practical Code Examples

Enqueue a Direct Message

import Foundation

// Initialize the store with Keychain-backed encryption
let outboxStore = MessageOutboxStore(
    keychain: appKeychain,
    fileURL: FileManager.default
        .urls(for: .documentDirectory, in: .userDomainMask)[0]
        .appendingPathComponent("outbox.enc")
)

let router = MessageRouter(outboxStore: outboxStore)

// Queue a message; encryption and persistence happen synchronously
await router.sendMessage(
    DirectMessage(id: UUID(), text: "Hello, world!", timestamp: Date()),
    to: recipientPeerID
)

Flush Acknowledged Messages

// Called from the transport layer when an ACK packet arrives
router.flushOutbox(for: confirmedPeerID)
// All queued items for this peer that have been ACK'd are removed from disk

Restore Pending Messages on Launch

let outboxStore = MessageOutboxStore(keychain: appKeychain, fileURL: outboxURL)

// Recover state after app termination
let pending = outboxStore.load()  // Returns [PeerID: [QueuedMessage]]
router.restorePendingMessages(pending)

// Router now schedules background retries for any remaining entries

Handle TTL or Attempt-Cap Drops

router.onMessageDropped = { peerID, messageID in
    // Surface failure to the user or log analytics
    print("Failed to deliver message \(messageID) to \(peerID) after max retries")
    // UI update omitted for brevity
}

Summary

  • The Sender Outbox is a per-peer, encrypted queue implemented in MessageOutboxStore.swift that persists outbound DMs until delivery is confirmed.
  • Exactly-once delivery is achieved by removing messages only after receiving an ACK from the recipient, while TTL and attempt caps prevent resource exhaustion.
  • Encryption uses symmetric keys derived from peer fingerprints and Keychain-stored secrets, protecting message content at rest.
  • Automatic recovery after app restart is handled by load(), which restores the in-memory queue from disk so the MessageRouter can resume sending without user intervention.
  • Failure handling is delegated via the onMessageDropped closure, allowing the UI to react to unreachable peers or expired messages.

Frequently Asked Questions

How does the Sender Outbox handle app restarts?

The MessageOutboxStore.load() method reads the persisted encrypted file from disk and reconstructs the [PeerID: [QueuedMessage]] dictionary. When the MessageRouter initializes, it calls restorePendingMessages() with this data, automatically re-scheduling any unsent messages for delivery. This ensures that force-quitting or crashing the app never loses pending outbound traffic.

What triggers a message to be removed from the outbox?

A message is removed only when flushOutbox(for:) is called after the router receives a valid acknowledgment (ACK) from the remote peer. If the message exceeds its configured TTL or attempt count before an ACK arrives, the router invokes onMessageDropped and then deletes the entry. This dual-path removal guarantees that the user is notified of failures while successful deliveries are idempotent.

How is the outbox encrypted to protect message privacy?

Each queued message is encrypted using a symmetric key derived from the recipient’s fingerprint and a master secret retrieved from the iOS Keychain. The MessageOutboxStore performs this encryption before writing to the file system, ensuring that the raw plaintext of DMs is never exposed in device backups or file system snapshots.

Where are the reliability guarantees tested in the codebase?

Unit and integration tests in MessageOutboxStoreTests.swift verify atomic persistence and recovery behavior, while MessageRouterTests.swift covers TTL expiry, attempt-cap logic, and ACK-driven flushing. Additional architectural context regarding outbox durability during peer-ID rotation can be found in PEER-ID-ROTATION.md at the repository root.

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 →