How Multi-Hop Message Routing Works in BitChat's BLE Mesh: A Technical Deep Dive

BitChat implements a store-and-forward Bluetooth Low Energy mesh where every device acts as a relay, using deterministic source routing and degree-based throttling to deliver messages across multiple radio hops without requiring internet infrastructure.

The open-source BitChat application (permissionlesstech/bitchat) creates a decentralized messaging network by turning smartphones into Bluetooth relay nodes. Unlike traditional client-server architectures, this multi-hop message routing mechanism enables private messages to traverse intermediate devices when sender and recipient are not within direct radio range. The implementation relies on three coordinated policies that handle route origination, per-hop forwarding decisions, and traffic-aware relay throttling.

The Three-Stage Routing Architecture

BitChat's mesh routing operates as a pipeline through three distinct Swift modules. Each stage runs serially on the engine queue (mesh.message) to ensure thread-safe access to topology state, while actual BLE I/O executes asynchronously on the bleQueue.

Stage 1: Source-Route Origination (BLESourceRouteOriginationPolicy.swift)

Before broadcasting a directed packet, the originator must decide whether to embed a source route—a pre-computed list of intermediate hops stored as [Data]. In BLESourceRouteOriginationPolicy.swift, the route() function performs five validation gates:

  1. Verify the local device is the original sender
  2. Confirm the message has a single recipient (directed traffic)
  3. Check sufficient TTL headroom remains
  4. Ensure the recipient is not directly connected
  5. Validate that MeshTopologyTracker can compute a viable route

If all conditions pass, the function attaches the routing data to the packet. Otherwise, the message floods as a standard broadcast.

// Inside BLEService.sendMessage(...)
let route = BLESourceRouteOriginationPolicy.route(
    for: packet,
    to: recipient,
    localPeerIDData: myPeerIDData,
    isRecipientConnected: isPeerConnected,
    shouldAttemptRoute: shouldAttemptRoute,
    computeRoute: computeRoute)          // ↳ consults MeshTopologyTracker
packet.route = route
broadcastPacket(packet)                  // broadcast with optional source-route

Stage 2: Forward-Path Planning (BLERouteForwardingPolicy.swift)

When a node receives a packet via BLERouteForwardingPolicy.swift, the plan() function determines the forwarding strategy by inspecting the embedded route. The policy returns one of three commands:

  • .allowFloodRelay: Process the packet normally and potentially flood to neighbors
  • .suppressFloodRelay: Consume the packet locally (destination reached or duplicate detected)
  • .forward(relayed, nextHop): Relay the packet to a specific next hop with decremented TTL

If a source route exists, the node locates its own position in the array. Intermediate nodes forward to the next entry, while the penultimate hop either delivers directly or falls back to flooding if the final destination is temporarily disconnected.

let plan = BLERouteForwardingPolicy.plan(
    for: packet,
    localPeerID: myPeerID,
    localRoutingData: myRoutingData,
    routingPeer: routingPeer,
    isPeerConnected: isPeerConnected)

switch plan {
case .allowFloodRelay:
    // ordinary flood – may be suppressed later by RelayController
case .suppressFloodRelay:
    // do nothing, packet has been handled
case .forward(let relayed, let nextHop):
    // schedule a relay of `relayed` to `nextHop`
    scheduleRelay(relayed, to: nextHop)
}

Stage 3: Relay-Policy Enforcement (RelayController.swift)

Before any transmission occurs, RelayController.decide() in RelayController.swift applies congestion control. The function evaluates mesh degree (neighbor count) against TransportConfig.bleHighDegreeThreshold, packet type (handshake, voice fragment, board post), and current TTL to calculate:

  • Whether to relay (shouldRelay: Bool)
  • The new TTL cap (newTTL: UInt8)
  • A randomized transmission delay (delayMs: Int)

High-degree nodes in dense clusters receive stricter TTL limits and longer jitter delays to prevent exponential airtime explosion, keeping the BLE duty cycle within iOS and Android power-saving constraints.

let decision = RelayController.decide(
    ttl: packet.ttl,
    senderIsSelf: false,
    recipientIsSelf: false,
    isEncrypted: packet.isEncrypted,
    isDirectedEncrypted: packet.isDirected && packet.isEncrypted,
    isFragment: packet.isFragment,
    isDirectedFragment: packet.isDirected && packet.isFragment,
    isHandshake: packet.type == .handshake,
    isAnnounce: packet.type == .announce,
    isRequestSync: packet.type == .requestSync,
    degree: meshDegree,
    highDegreeThreshold: TransportConfig.bleHighDegreeThreshold)

guard decision.shouldRelay else { return }
let finalPacket = packet.withTTL(decision.newTTL)
engineScheduler.schedule(after: .milliseconds(decision.delayMs)) {
    self.sendPacket(finalPacket, to: nextHop)
}

End-to-End Routing Examples

Sending a Private Message Across Two Hops

When Device A sends a direct message to Device C (two hops away), the system first consults MeshTopologyTracker to resolve the path [Device B, Device C]:

let recipient = PeerID(hex: "a1b2c3d4e5f6")
let content = "Hey, you're 2 hops away!"

var packet = BitchatPacket(
    type: MessageType.message.rawValue,
    senderID: myPeerIDData,
    recipientID: recipient.data,
    timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
    payload: Data(content.utf8),
    ttl: TransportConfig.messageTTLDefault)

// Compute and attach source route
let route = meshTopology.route(to: recipient)  // → [Data]?
packet.route = route

bleService.broadcastPacket(packet)

The packet broadcasts from Device A. Device B receives it, recognizes itself as the first hop in the source route, and schedules a relay to Device C using the scheduleRelay pattern shown in Stage 3.

Relay Scheduling with Degree Awareness

Intermediate nodes apply the RelayController decision before rebroadcasting:

func scheduleRelay(_ packet: BitchatPacket, to nextHop: PeerID) {
    let decision = RelayController.decide(
        ttl: packet.ttl,
        senderIsSelf: false,
        recipientIsSelf: false,
        isEncrypted: packet.isEncrypted,
        isDirectedEncrypted: packet.isDirected && packet.isEncrypted,
        isFragment: packet.isFragment,
        isDirectedFragment: packet.isDirected && packet.isFragment,
        isHandshake: packet.type == MessageType.handshake.rawValue,
        isAnnounce: packet.type == MessageType.announce.rawValue,
        isRequestSync: packet.type == MessageType.requestSync.rawValue,
        degree: meshDegree,
        highDegreeThreshold: TransportConfig.bleHighDegreeThreshold)

    guard decision.shouldRelay else { return }
    
    var relayed = packet
    relayed.ttl = decision.newTTL
    
    engineScheduler.schedule(after: .milliseconds(decision.delayMs)) {
        self.bleService.sendPacket(relayed, to: nextHop)
    }
}

Why This Design Works

Deterministic source routing eliminates per-hop pathfinding overhead. By computing the full route once at the originator using MeshTopologyTracker, intermediate relays avoid expensive graph algorithms on battery-constrained mobile devices.

Single-hop awareness enables stateless forwarding. Each node only needs to locate its own localRoutingData within the embedded route array to determine if it is the final relay or should forward onward, requiring no shared routing tables.

Flood fallback ensures resilience. If the source route is missing, malformed, or the next hop is offline, the packet automatically degrades to classic flood-and-prune behavior, guaranteeing message delivery in highly dynamic topologies where nodes frequently join and leave.

Traffic-aware throttling preserves battery life. The RelayController inspects local mesh density and packet characteristics to cap TTL and inject jitter, preventing broadcast storms in crowded environments while maintaining low latency in sparse chains.

Summary

  • BitChat's BLE mesh uses a three-stage pipeline for multi-hop routing: source-route origination (BLESourceRouteOriginationPolicy.swift), forward-path planning (BLERouteForwardingPolicy.swift), and relay-policy enforcement (RelayController.swift).
  • Source routing embeds the full path as [Data] in the packet header, computed once by the sender via MeshTopologyTracker.
  • Intermediate nodes locate themselves in the route array to determine whether to forward to a specific next hop or flood to neighbors.
  • Congestion control dynamically adjusts TTL caps and transmission delays based on node degree and packet type to comply with mobile OS power restrictions.
  • All routing logic executes on the mesh.message engine queue, while bleQueue handles physical layer I/O according to the architecture specified in BLE-ARCHITECTURE-V3.md.

Frequently Asked Questions

How does BitChat prevent infinite relay loops in the BLE mesh?

The RelayController.decide() function caps the TTL (time-to-live) based on mesh density and packet type. Each relay decrements the TTL, and packets reaching zero are automatically discarded. Additionally, nodes track recently seen message IDs to suppress duplicate transmissions, preventing cycles even if the source route contains stale topology data.

What happens if an intermediate node in the source route goes offline?

If the next hop specified in the source route is unreachable, BLERouteForwardingPolicy.plan() falls back to flood behavior for that packet. The current node broadcasts the message to all neighbors, allowing the mesh to route around failed nodes without requiring the originator to recalculate the path. This hybrid approach balances efficiency (source routing) with resilience (flooding).

Why does BitChat use source routing instead of dynamic hop-by-hop routing?

Source routing minimizes computational overhead on mobile devices. Computing a full mesh path at the originator via MeshTopologyTracker.route(to:) eliminates the need for intermediate nodes to run Dijkstra or A* algorithms. This design preserves battery life, since simple array indexing (localRoutingData position lookup) replaces complex graph traversals at every hop.

Where is the mesh topology information stored for route computation?

The MeshTopologyTracker maintains the network graph state, tracking which peers are directly connected and their respective routing data. When BLESourceRouteOriginationPolicy.route() requires a path, it invokes the computeRoute closure provided by the tracker, which returns an ordered list of intermediate nodes or nil if no path exists. This topology state updates dynamically as BLE advertisements and handshakes are processed.

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 →