How BitChat Manages Message TTL Expiration in the Outbox: Implementation Guide

BitChat enforces a strict 24-hour Time-to-Live (TTL) policy for queued private messages through a three-layered defense mechanism in MessageRouter.swift, combining periodic background sweeps, flush-time validation, and secure-retry checks to automatically purge stale messages from memory and encrypted disk storage.

BitChat, an open-source peer-to-peer messaging application maintained by permissionlesstech/bitchat, implements a store-and-forward outbox system that guarantees messages do not linger indefinitely. Understanding how BitChat manages message TTL expiration in the outbox is critical for developers building on the protocol or debugging message delivery failures. The system guarantees that any undelivered private message queued in the outbox is automatically removed after exactly 24 hours through multiple redundant enforcement points.

TTL Architecture Overview

The TTL enforcement strategy relies on a centralized constant and three distinct execution phases that ensure no expired message survives regardless of the code path taken.

The 24-Hour Constant Definition

In bitchat/Services/MessageRouter.swift, the TTL duration is defined as a static constant:

private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours

This value serves as the single source of truth for all expiration calculations across the routing layer. According to the BitChat source code, every queued message carries a timestamp property recorded at enqueue time, and the router calculates age using now.timeIntervalSince(message.timestamp) compared against this constant.

Three-Layer Enforcement Points

BitChat implements defense-in-depth through three specific checkpoints:

  1. Periodic Background Sweep – The startBridgeDepositSweep task runs every two minutes and invokes cleanupExpiredMessages() to scan the entire outbox.
  2. Flush-Time Validation – Whenever flushOutbox(for:) prepares to transmit a peer’s queued messages, it filters entries older than messageTTLSeconds before attempting delivery.
  3. Secure-Retry Checks – During session recovery, retrySecurePrivateMessagesAfterAuthentication validates TTL before reattempting delivery of pending secure messages.

Message Expiration Mechanics

The expiration logic follows a consistent pattern across all enforcement points, combining in-memory cleanup with encrypted persistence updates.

Age Calculation and Dropping Logic

When evaluating a QueuedMessage, the router performs the following calculation:

let age = now.timeIntervalSince(message.timestamp)
if age > Self.messageTTLSeconds {
    // Trigger expiration path
}

Upon expiration detection, the system:

  • Removes the entry from the in-memory outbox dictionary via removeQueuedMessage
  • Invokes dropMessage to log the event, increment the outboxDropped metric, and trigger UI callbacks through onMessageDropped
  • Persists the modified outbox state to disk

Persistence Update Flow

The MessageOutboxStore handles encrypted on-disk storage but does not enforce TTL itself. After any removal operation, the router explicitly calls persistOutbox(), which writes the current snapshot to the encrypted store. This architecture separates policy enforcement (router) from data persistence (store), ensuring the store remains agnostic to business logic.

Code Implementation Details

The following sections detail the specific implementation locations and patterns used to enforce the 24-hour limit.

Periodic Cleanup Implementation

The background maintenance task initiates cleanup every two minutes:

// Inside startBridgeDepositSweep (lines 6-9)
func startBridgeDepositSweep() {
    // Other bridge logic...
    cleanupExpiredMessages()
}

The cleanupExpiredMessages() method iterates through every peer’s message queue, applying the TTL calculation and removing expired entries. This prevents stale messages from accumulating during periods of low connectivity.

Flush-Time Validation

Before transmitting queued messages to a peer, flushOutbox(for:) performs a final TTL check (lines 107-115):

func flushOutbox(for peerID: PeerID) {
    guard let messages = outbox[peerID] else { return }
    
    let validMessages = messages.filter { message in
        let age = Date().timeIntervalSince(message.timestamp)
        guard age <= Self.messageTTLSeconds else {
            dropMessage(message, reason: .expired)
            return false
        }
        return true
    }
    
    // Proceed with sending validMessages...
}

This guards against race conditions where a message might expire between the periodic sweep and the actual transmission attempt.

Secure Retry Handling

For encrypted sessions requiring authentication, retrySecurePrivateMessagesAfterAuthentication (lines 55-57) applies the same TTL guard before retrying delivery. This ensures that messages do not spuriously retry after the recipient comes online if the 24-hour window has already elapsed.

Practical Usage and Monitoring

Developers interacting with the BitChat outbox can observe and manually trigger TTL behavior through the following patterns.

Enqueueing a message automatically sets the timestamp:

router.sendPrivate(
    "Hey, see you at 5 pm",
    to: peerID,
    recipientNickname: "Alice",
    messageID: UUID().uuidString
)

Forcing immediate cleanup (useful in testing):

router.cleanupExpiredMessages()

Monitoring expiration events via callback:

router.onMessageDropped = { messageID, peerID in
    print("Message \(messageID) for \(peerID.id) expired and was removed")
}

Each expiration generates debug logging (e.g., "⏰ Expired queued message …") and updates the outboxDropped metric, enabling operational monitoring of TTL effectiveness.

Summary

  • BitChat enforces a strict 24-hour TTL defined by messageTTLSeconds in bitchat/Services/MessageRouter.swift.
  • Three redundant checkpoints—periodic sweeps, flush-time validation, and secure-retry checks—ensure comprehensive expiration coverage.
  • Expired messages trigger dropMessage(), which removes entries from memory, updates metrics, notifies UI callbacks, and triggers persistence updates.
  • The MessageOutboxStore handles encrypted disk storage but relies on the router to enforce TTL policy, calling persistOutbox() after modifications.
  • Background maintenance runs every two minutes via startBridgeDepositSweep, preventing stale message accumulation.

Frequently Asked Questions

What is the default TTL for messages in BitChat?

BitChat defines a default TTL of 24 hours (86,400 seconds) through the messageTTLSeconds constant in bitchat/Services/MessageRouter.swift. Any private message remaining in the outbox beyond this duration is automatically purged regardless of delivery status.

How often does BitChat check for expired messages?

The system performs TTL validation at three intervals: a background sweep every two minutes via startBridgeDepositSweep, immediate checks during every outbox flush operation through flushOutbox(for:), and validation during secure session recovery in retrySecurePrivateMessagesAfterAuthentication.

Does the encrypted MessageOutboxStore enforce TTL automatically?

No. According to the BitChat source code, bitchat/Services/Courier/MessageOutboxStore.swift handles encrypted persistence but remains policy-agnostic. The MessageRouter enforces TTL logic in memory and explicitly calls persistOutbox() to write the filtered state to disk after removing expired entries.

Can developers manually trigger TTL cleanup in BitChat?

Yes. Developers can invoke router.cleanupExpiredMessages() directly on the MessageRouter instance to force an immediate scan and removal of all expired messages. This is particularly useful for unit testing expiration logic or manually clearing stale queues during development.

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 →