How Bitchat Android Handles Message Retries for Unreachable Peers
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). 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 = 100messages - Global TTL:
OUTBOX_MESSAGE_TTL_MS = 24 hours - Automatic eviction: Old or excess entries are silently dropped
// 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:
- Expire old entries — Remove messages exceeding 24 hours
- Flush sendable messages — Deliver queued items where mesh/Nostr routes now exist
- Kick handshake attempts — For visible peers lacking Noise sessions, trigger
kickHandshake()
// 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):
| Attempt | Delay |
|---|---|
| 1 | 5 seconds |
| 2 | 15 seconds |
| 3 | 30 seconds |
| 4+ | 60 seconds |
// 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) exposes retryPendingPrivateMedia(), which forwards to the scheduler logic. This powers UI retry buttons without exposing internal router state.
// 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) 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.
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 →