# How Bitchat Queues Messages Offline When Bluetooth and Nostr Are Unavailable

> Bitchat queues offline messages when Bluetooth and Nostr fail. Discover how durable storage and courier delegation ensure message delivery upon reconnection.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: internals
- Published: 2026-08-09

---

**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](https://github.com/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`](https://github.com/permissionlesstech/bitchat/blob/main/MessageRouter.swift), specifically within the `sendPrivate(_:to:recipientNickname:messageID:)` method. Before attempting delivery, the router evaluates three conditions in strict order:

1. A connected transport with an active secure session
2. A connected transport without a secure session  
3. 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:

```swift
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`](https://github.com/permissionlesstech/bitchat/blob/main/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:

```swift
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.noiseKey` for envelope sealing
- **Bridge Deposit**: Calls `requestBridgeCourierDeposit` to optionally drop the message on a Nostr bridge if bridge mode is enabled
- **Peer Courier Selection**: Filters `eligibleCouriers` from connected transports and invokes `sendCourierMessage` to 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`:

```swift
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**: `MessageRouter` evaluates transport reachability before every send; failure triggers the offline queue path in `sendPrivate`.
- **Durability**: Messages are wrapped in `QueuedMessage` structs and persisted via `MessageOutboxStore`, 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**: `flushOutbox` automatically 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.