# How Bitchat Android Handles Message Retries for Unreachable Peers

> Bitchat Android ensures message delivery with a two-layer retry system. Discover its TTL-based out-box queue and exponential back-off handshake for unreachable peers.

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

---

**Bitchat Android guarantees eventual delivery of private messages through a two-layer retry system: an in-memory out-box queue with TTL-based expiration and an exponential back-off handshake scheduler in the MessageRouter service.**

The open-source Bitchat Android messaging app, developed by permissionlesstech, implements a sophisticated retry mechanism that allows private messages to reach peers even when immediate connectivity is unavailable. This article examines the technical implementation of message retries for unreachable peers, based on the actual source code in the repository.

## The Two-Layer Retry Architecture

Bitchat Android's retry mechanism lives in the **MessageRouter** service ([`app/src/main/java/com/bitchat/android/services/MessageRouter.kt`](https://github.com/permissionlesstech/bitchat-android/blob/main/app/src/main/java/com/bitchat/android/services/MessageRouter.kt)). The design separates concerns between message queuing and transport-level handshakes:

- **Layer 1: Out-box queue** — Stores messages that cannot be sent immediately due to missing mesh or Nostr routes
- **Layer 2: Handshake back-off** — Manages periodic attempts to establish Noise protocol sessions with visible peers

This separation ensures that the application can queue hundreds of messages while aggressively but respectfully retrying transport connections.

## Out-Box Queue Implementation

When `sendPrivate()` cannot find an immediate route, the `enqueue()` method stores the message in an in-memory `outbox` map. Each entry tracks the enqueue timestamp for TTL enforcement.

Key constraints on the out-box:

- **Per-peer limit**: `OUTBOX_MAX_PER_PEER = 100` messages
- **Global TTL**: `OUTBOX_MESSAGE_TTL_MS = 24 hours`
- **Automatic eviction**: Old or excess entries are silently dropped

```kotlin
// Sending when no route exists → message queued
val result = router.sendPrivate(
    content = "Hello from offline",
    toPeerID = peerId,
    recipientNickname = "Bob",
    messageID = UUID.randomUUID().toString()
)
// result == RouteResult.QUEUED

```

The queue is not persistent to disk—messages survive process restarts only if the Android OS keeps the service alive. This trade-off prioritizes privacy and simplicity over guaranteed durability.

## Periodic Scheduler and Tick Processing

The `startOutboxScheduler()` method launches a Kotlin coroutine that calls `tickOutbox()` every `OUTBOX_TICK_MS = 2_000` milliseconds. Each tick performs three operations:

1. **Expire old entries** — Remove messages exceeding 24 hours
2. **Flush sendable messages** — Deliver queued items where mesh/Nostr routes now exist  
3. **Kick handshake attempts** — For visible peers lacking Noise sessions, trigger `kickHandshake()`

```kotlin
// Located in MessageRouter.kt
private fun tickOutbox() {
    expireOldEntries()
    flushOutbox()
    
    // For each visible peer without session...
    peersNeedingHandshake.forEach { kickHandshake(it) }
}

```

## Exponential Back-Off Handshake Retry

The `kickHandshake()` method implements the critical back-off logic that prevents network congestion. Before initiating a Noise handshake, it checks `retryState` to verify that enough time has elapsed since the last attempt.

Back-off schedule defined in `AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS` ([`app/src/main/java/com/bitchat/android/util/AppConstants.kt`](https://github.com/permissionlesstech/bitchat-android/blob/main/app/src/main/java/com/bitchat/android/util/AppConstants.kt)):

| Attempt | Delay |
|---------|-------|
| 1 | 5 seconds |
| 2 | 15 seconds |
| 3 | 30 seconds |
| 4+ | 60 seconds |

```kotlin
// From AppConstants.kt
val HANDSHAKE_RETRY_BACKOFF_MS = listOf(5_000L, 15_000L, 30_000L, 60_000L)

```

After each failed handshake, the router records the next allowed attempt time. The `retryState` map tracks per-conversation `handshakeAttempts` counters and timestamps.

## Session Establishment and Message Flush

When `onSessionEstablished()` fires—either from successful handshake or incoming peer connection—the retry state is immediately cleared via `resetRetry()`. All queued messages for that peer are then flushed in order.

This "clear-on-success" design ensures that:

- No redundant handshake attempts occur for active sessions
- Queued messages deliver with minimal latency once connectivity resumes
- Retry counters reset for future disconnection events

## Message Expiration Handling

The `expireOldEntries()` method enforces the 24-hour TTL. Expired messages invoke the optional `onMessageExpired` callback, allowing UI layers to notify users of delivery failure.

Expiration is checked on every scheduler tick, so the actual deletion may occur up to 2 seconds after TTL expiry.

## Public API for Manual Retries

The `MediaSendingManager` ([`app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt`](https://github.com/permissionlesstech/bitchat-android/blob/main/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt)) exposes `retryPendingPrivateMedia()`, which forwards to the scheduler logic. This powers UI retry buttons without exposing internal router state.

```kotlin
// Manual retry trigger (e.g., user taps "Retry")
mediaSendingManager.retryPendingPrivateMedia(peerID = targetPeer)

```

## Comparison: MessageRouter vs. Mesh-Level Retries

Bitchat Android implements retries at two distinct layers:

| Layer | Component | Responsibility |
|-------|-----------|--------------|
| Application | `MessageRouter` | Queue messages, manage handshakes,TTL expiration |
| Transport | `RetryingControlPacketSender` | Low-level packet retransmission for mesh reliability |

The `RetryingControlPacketSender` ([`app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt`](https://github.com/permissionlesstech/bitchat-android/blob/main/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt)) handles UDP-style packet retries independently. Application developers interact only with `MessageRouter`; mesh retries are transparent.

## Summary

- **Out-box queue**: In-memory storage with 100-message-per-peer limit and 24-hour TTL
- **Scheduler-driven**: 2-second tick interval checks all queued conversations
- **Exponential back-off**: 5s → 15s → 30s → 60s handshake retry schedule
- **Automatic cleanup**: Success clears retry state; expiry invokes failure callbacks
- **Resource bounded**: Caps prevent memory exhaustion during extended peer unavailability

## Frequently Asked Questions

### How long does Bitchat Android keep retrying a message?

Messages remain in the out-box for **24 hours** (`OUTBOX_MESSAGE_TTL_MS`). After this TTL expires, `expireOldEntries()` removes them and optionally fires `onMessageExpired`. Handshake retries follow an exponential back-off schedule but do not affect message persistence.

### What happens when a peer comes back online?

When `onSessionEstablished()` detects a new Noise session, it immediately calls `resetRetry()` to clear back-off state and flushes all queued messages for that peer. No manual intervention is required—the scheduler handles delivery automatically.

### Can users manually trigger a retry?

Yes. The `MediaSendingManager.retryPendingPrivateMedia(peerID)` method allows UI components to force immediate re-evaluation of a peer's queue. This does not bypass back-off timers for handshakes but does trigger `tickOutbox()` logic for that specific peer.

### Is the out-box persisted across app restarts?

No. The `outbox` map is held in memory within the `MessageRouter` service. If Android kills the process, queued messages are lost. This design prioritizes privacy (no local message database) over guaranteed delivery—true to the application's permissionless, ephemeral messaging philosophy.