How BitChat Handles BLE Message Fragmentation for Payloads Exceeding the MTU

BitChat automatically splits packets larger than the 512-byte BLE MTU into versioned fragments with unique binary headers, transmits them as standard BitchatPacket messages, and re-assembles them on the receiver using per-sender buffers that enforce size limits, route preservation, and stall recovery.

BitChat is an open-source, peer-to-peer messaging protocol built by permissionlesstech/bitchat that operates over Bluetooth Low Energy (BLE). Because BLE links typically enforce a maximum transmission unit (MTU) of 512 bytes, the protocol must fragment oversized payloads before transmission and reassemble them upon receipt. According to the source code in bitchat/Services/BLE/, this fragmentation layer operates transparently across three distinct stages: outbound planning, binary encoding, and inbound reassembly.

Fragment Planning and Version Selection

When a packet is handed to the BLE transport, BLEOutboundFragmentPlanner.makePlan in BLEOutboundFragmentPlanner.swift evaluates whether fragmentation is required and computes the optimal transmission strategy.

The planner first serializes the entire packet via request.packet.toBinaryData(padding:) to measure its total size. It then applies a sizing policy to determine whether to use fragment version 1 (simple broadcasts without routing) or version 2 (when the original packet carries a non-empty route). Version 2 fragments inherit the exact route field from the parent packet to preserve source-routing semantics and avoid falling back to flooding, as documented in SOURCE_ROUTING.md.

The final chunk size is calculated as the greater of 64 bytes (the enforced minimum) or the user-requested size, subtracting header overhead from the 512-byte MTU. The binary data is then split using stride(from:to:by:) into individual chunks.

Fragment Header Structure

For each chunk, makeFragmentPacket constructs a binary header containing:

  • fragmentID – 8 bytes: A random identifier shared by all fragments of the same message
  • index – 2 bytes: Zero-based fragment sequence number
  • total – 2 bytes: Total fragment count
  • originalType – 1 byte: The MessageType of the reassembled packet (e.g., .message)
  • fragmentData – Variable: The payload slice (≤ chunk size)

The function returns a BLEOutboundFragmentPlan describing the packets, chosen version, chunk size, and inter-fragment spacing (which varies for directed vs. broadcast traffic).

Fragment Encoding and Packet Wrapping

Each fragment is encoded as a standard BitchatPacket with type = MessageType.fragment.rawValue, defined in MessageType.swift. Because fragments use the generic packet structure, they benefit from standard binary encoding (BinaryProtocol.encode), encryption, and signing.

The BitchatPacket documentation explicitly notes this behavior:

“Packets larger than BLE MTU (512 bytes) are automatically fragmented” – BitchatPacket.swift

For legacy private-media v1 transfers, the planner enforces a hard limit of 256 fragments (the maximum Android can accept) via BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible.

Inbound Reassembly and Buffer Management

When a BLE peripheral receives a fragment, BLEService.handleFragment delegates to BLEFragmentHandler.handle in BLEFragmentHandler.swift. The handler executes four critical steps:

  1. Header extraction – BLEFragmentHeader(packet:) validates payload length and extracts the 64-bit sender key, fragment ID, index, total count, and original type. Invalid headers result in immediate drops.
  2. Self-fragment suppression – Fragments originating from the local peer ID are discarded to prevent reprocessing, though broadcast fragments are still tracked for gossip-sync purposes.
  3. Assembly tracking – appendFragment invokes BLEFragmentAssemblyBuffer.append in BLEFragmentAssemblyBuffer.swift. The buffer creates a per-sender entry on first receipt (startAssemblyIfNeeded), enforces size limits (assemblyLimit(for:)), and concatenates payloads in order.
  4. Re-injection – When fragments.count == header.total, the buffer returns .complete. The handler decodes the binary blob back to a BitchatPacket, resets its ttl to 0, and injects it into the normal receive pipeline via processReassembledPacket.

Stall Detection and Recovery

The assembly buffer tracks incomplete broadcast streams. Assemblies that have not received new fragments within a configurable interval are exposed via stalledBroadcastFragmentIDs, enabling the protocol to issue targeted REQUEST_SYNC packets to retrieve missing pieces.

Version 1 vs. Version 2 Fragments

BitChat distinguishes between two fragmentation schemas to optimize routing efficiency:

  • Version 1 – Used for simple broadcasts without a route. The fragment header contains no routing metadata; fragments are forwarded by normal flooding.
  • Version 2 – Used when the original packet carries a non-empty route. All fragments inherit the same route field, preserving source-routing semantics and preventing fallback to flooding. The planner automatically sets fragmentVersion = 2 when packet.route is non-empty.

As noted in the design documentation: “All fragments MUST be marked as Version 2 … otherwise they would fall back to flooding” – SOURCE_ROUTING.md.

Practical Implementation Examples

Fragmenting a Large Packet for BLE Transmission

import BitFoundation

// Assume we have a BitchatPacket > 512 bytes
let largePacket: BitchatPacket = // ... built elsewhere

let request = BLEOutboundFragmentTransferRequest(
    packet: largePacket,
    maxChunk: nil,              // Allow planner to optimize
    pad: true,
    directedPeer: nil,          // Broadcast transmission
    // ... additional configuration
)

let plan = BLEOutboundFragmentPlanner.makePlan(
    for: request,
    defaultChunkSize: 128,
    bleMaxMTU: 512
)

// plan?.fragmentPackets contains N fragments ready for transmission
// Source: BLEOutboundFragmentPlanner.makePlan – L26-L74

Receiving and Reassembling Fragments

let handler = BLEFragmentHandler(
    environment: BLEFragmentHandlerEnvironment(
        localPeerID: { myPeerID },
        trackPacketSeen: { packet in /* gossip tracking */ },
        appendFragment: { header in 
            fragmentBuffer.append(header, maxInFlightAssemblies: 64) 
        },
        isAcceptedIngressPayload: { packet, sender in 
            /* validation logic */ 
        },
        processReassembledPacket: { packet, from in
            // Packet is now complete and injected into normal pipeline
            receivePipeline.process(packet, from: from)
        }
    )
)

// Called by BLEService when raw data arrives
handler.handle(incomingBitchatPacket, from: remotePeerID)
// Source: BLEFragmentHandler.handle – L34-L73

Summary

  • Automatic splitting – BLEOutboundFragmentPlanner.makePlan divides payloads exceeding the 512-byte BLE MTU into chunks of at least 64 bytes, selecting Version 1 or 2 based on routing requirements.
  • Binary headers – Each fragment carries an 13-byte header (8-byte ID, 2-byte index, 2-byte total, 1-byte type) wrapped in a standard BitchatPacket with MessageType.fragment.
  • Per-sender assembly – BLEFragmentAssemblyBuffer manages reassembly, enforces size limits per packet type, and drops incomplete streams that exceed thresholds.
  • Route preservation – Version 2 fragments inherit the original packet's route to maintain source-routing integrity and avoid flooding.
  • Stall recovery – The buffer exposes stalled broadcast fragments, enabling targeted synchronization requests for missing pieces.

Frequently Asked Questions

What is the maximum payload size BitChat can fragment?

BitChat targets a 512-byte BLE MTU, subtracting header overhead to determine chunk size. While there is no explicit upper bound on the original payload, the private-media v1 compatibility layer caps fragments at 256 total pieces to accommodate Android receiver limits. Standard packets are constrained only by available memory and the per-type size limits enforced by BLEFragmentAssemblyBuffer.assemblyLimit(for:).

How does BitChat prevent routing loops with fragmented packets?

When a source-routed packet requires fragmentation, the planner automatically selects Version 2, ensuring all fragments inherit the original packet's route field. This preserves the exact source path and prevents the routing engine from falling back to broadcast flooding, which could create loops or redundant traffic.

What happens if a fragment is lost during transmission?

If a fragment is lost, the BLEFragmentAssemblyBuffer will never reach the expected fragment count (header.total), leaving the assembly incomplete. For broadcast traffic, these stalled assemblies are tracked and exposed via stalledBroadcastFragmentIDs. The protocol can then issue REQUEST_SYNC messages to specifically request the missing fragment indices from peers.

How does BitChat handle self-fragments during relay?

The BLEFragmentHandler compares the fragment's sender ID against the local peer ID. If they match, the fragment is discarded (self-fragment suppression) to prevent the node from reprocessing its own traffic. However, broadcast fragments are still tracked via trackPacketSeen to maintain gossip synchronization state across the mesh.

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 →