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

> Explore BitChat's BLE mesh multi-hop message routing. Learn how deterministic routing and throttling enable reliable communication across devices without internet.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: deep-dive
- Published: 2026-07-31

---

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

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

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

```swift
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]`:

```swift
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:

```swift
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`](https://github.com/permissionlesstech/bitchat/blob/main/BLESourceRouteOriginationPolicy.swift)), forward-path planning ([`BLERouteForwardingPolicy.swift`](https://github.com/permissionlesstech/bitchat/blob/main/BLERouteForwardingPolicy.swift)), and relay-policy enforcement ([`RelayController.swift`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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.