How BitChat Manages BLE Fragmentation and Reassembly for Messages Exceeding MTU
BitChat automatically splits packets larger than the 512-byte BLE MTU into versioned fragments with unique headers and reassembles them on the receiver side using per-sender buffers that enforce size limits and handle stall recovery.
BitChat, an open-source peer-to-peer messaging protocol from permissionlesstech/bitchat, transports messages over Bluetooth Low Energy (BLE) links constrained by a typical maximum transmission unit (MTU) of 512 bytes. When protocol messages exceed this limit, the system employs a sophisticated BLE fragmentation and reassembly strategy that preserves routing semantics, prevents self-reception loops, and supports legacy Android constraints. The implementation spans three distinct layers: fragment planning, binary encoding, and stateful reassembly.
Fragment Planning and Chunk Sizing
When the BLE transport layer receives an oversized packet, it delegates fragmentation decisions to BLEOutboundFragmentPlanner.makePlan in BLEOutboundFragmentPlanner.swift.
The planner first serializes the entire packet using request.packet.toBinaryData(padding:). It then applies a sizing policy to determine whether to use fragment version 1 (for 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, requiring larger header overhead that is subtracted from the available MTU.
The final chunk size is calculated as the greater of 64 bytes (minimum threshold) and the user-requested or computed optimal size. The serialized data is then split using stride into discrete chunks, each wrapped in a fragment packet via makeFragmentPacket.
Fragment Encoding and Binary Header Structure
Each fragment is encoded as a standard BitchatPacket with type = MessageType.fragment.rawValue, defined in MessageType.swift. The binary payload prepends a compact header structure containing:
fragmentID: 8-byte random identifier shared across all fragments of the same messageindex: 2-byte zero-based fragment numbertotal: 2-byte total fragment countoriginalType: 1-byte original packet type (e.g.,MessageType.message)fragmentData: Variable payload slice up to the calculated chunk size
Because fragments use the standard BitchatPacket container, they inherit generic binary encoding via BinaryProtocol.encode / decode and can be signed, encrypted, or routed exactly like non-fragmented traffic. As noted in BitchatPacket.swift, packets exceeding the BLE MTU are automatically funneled through this fragmentation pipeline.
Fragment Reassembly and Reception Pipeline
Inbound fragment processing is orchestrated by BLEFragmentHandler.handle in BLEFragmentHandler.swift. The handler executes a strict validation and assembly sequence:
- Header Extraction:
BLEFragmentHeader(packet:)validates payload length and extracts the sender-derived 64-bit key, fragment ID, index, total count, and original type. Invalid fragments are immediately dropped. - Self-Suppression: Fragments originating from the local peer are ignored to prevent processing echoes of the device's own transmissions.
- Gossip Tracking: Broadcast fragments are recorded via
trackPacketSeento prevent redundant retransmission during gossip synchronization. - Assembly Buffering: Valid fragments feed into
BLEFragmentAssemblyBuffer.append, which maintains per-sender state in BLEFragmentAssemblyBuffer.swift.
The assembly buffer creates a new entry via startAssemblyIfNeeded when encountering the first fragment of a sequence. It enforces size limits via assemblyLimit(for:) based on the original packet type, discarding the entire stream if projected reconstruction would exceed safety thresholds. When fragments.count == header.total, the buffer concatenates payload pieces in index order, removes temporary state, and returns a .complete status.
Upon completion, the handler decodes the reassembled binary data back into a BitchatPacket, forces its ttl to 0 (indicating final hop arrival), and injects it into the normal receive pipeline via processReassembledPacket.
Stall Detection and Recovery
The assembly buffer exposes stalledBroadcastFragmentIDs to identify incomplete broadcast streams that have not received new fragments within a configurable interval. This enables targeted REQUEST_SYNC packets to request missing pieces from peers, ensuring robust delivery even under lossy RF conditions.
Version 1 vs. Version 2 Fragment Behavior
BitChat distinguishes between two fragmentation modes to preserve source-routing semantics:
- Version 1: Used for simple broadcasts without a route field. These fragments rely on standard flooding mechanics and carry minimal overhead.
- Version 2: Activated automatically when
packet.routeis non-empty. All fragments inherit the parent packet's route field, ensuring source-routed delivery semantics are maintained across the fragment stream. Without this version bump, fragments would fall back to flooding, violating the intended path constraints as documented in SOURCE_ROUTING.md.
The planner sets fragmentVersion = 2 automatically when routing metadata is detected, ensuring the fragment header accommodates the additional routing bytes without exceeding MTU.
Legacy Compatibility and Fragment Limits
For backwards compatibility with the legacy private-media v1 transfer path on Android, the planner enforces a maximum fragment count of 256. This limit is validated by BLEOutboundFragmentPlanner.isPrivateMediaV1Compatible, preventing transmission to older Android receivers that cannot handle larger fragment sequences.
Fragmenting and Reassembling Messages in Practice
Fragmenting a Large Packet for BLE Transmission
import BitFoundation
// Assume largePacket is a BitchatPacket > 512 bytes
let largePacket: BitchatPacket = // ... constructed elsewhere
let request = BLEOutboundFragmentTransferRequest(
packet: largePacket,
maxChunk: nil, // Allow planner to optimize size
pad: true,
directedPeer: nil, // Broadcast transmission
// Additional fields omitted for brevity
)
let plan = BLEOutboundFragmentPlanner.makePlan(
for: request,
defaultChunkSize: 128,
bleMaxMTU: 512
)
// plan?.fragmentPackets contains N fragments ready for transmission
// via BLEService.send(...)
Source: BLEOutboundFragmentPlanner.makePlan – BLEOutboundFragmentPlanner.swift
Receiving and Reassembling Fragments
let handler = BLEFragmentHandler(
environment: BLEFragmentHandlerEnvironment(
localPeerID: { myPeerID },
trackPacketSeen: { packet in
// Record for gossip deduplication
},
appendFragment: { header in
fragmentBuffer.append(
header,
maxInFlightAssemblies: 64
)
},
isAcceptedIngressPayload: { packet, sender in
// Validate sender permissions
true
},
processReassembledPacket: { packet, from in
// Handle fully reconstructed packet
receivePipeline.process(packet, from: from)
}
)
)
// Invoked by BLEService when raw packets arrive
handler.handle(incomingBitchatPacket, from: remotePeerID)
Source: BLEFragmentHandler.handle – BLEFragmentHandler.swift
Summary
- BLE fragmentation and reassembly in BitChat is automatic for packets exceeding the 512-byte MTU, handled by
BLEOutboundFragmentPlannerandBLEFragmentHandler. - Fragment headers carry 8-byte IDs, indexing metadata, and original type information to enable ordered reconstruction.
- Version 2 fragments preserve source-routing paths across fragment boundaries, while Version 1 relies on flooding.
- The
BLEFragmentAssemblyBufferenforces per-type size limits and provides stall detection for broadcast recovery. - Legacy Android compatibility caps fragment sequences at 256 pieces for private-media v1 transfers.
Frequently Asked Questions
What is the maximum message size BitChat can transmit over BLE?
While the BLE MTU is fixed at 512 bytes, BitChat supports arbitrarily large logical messages by splitting them into fragments. The practical limit is constrained by the BLEFragmentAssemblyBuffer size limits (which vary by packet type) and the 256-fragment cap for private-media v1 compatibility. Each fragment carries a 13-byte header overhead.
How does BitChat prevent receiving its own fragmented messages?
The BLEFragmentHandler performs self-fragment suppression by comparing the fragment sender ID against the local peer ID. If they match, the fragment is discarded (though broadcast fragments are still tracked for gossip synchronization purposes to prevent retransmission loops).
What happens if a fragment is lost during transmission?
Incomplete assemblies are tracked as stalled broadcasts if no new fragments arrive within a configurable timeout. The system exposes stalledBroadcastFragmentIDs to enable targeted REQUEST_SYNC packets that solicit missing fragments from peers, ensuring robust delivery without requiring full retransmission of the entire message.
Why does BitChat use two fragment versions?
Version 1 supports simple broadcast flooding without routing overhead. Version 2 is required when the original packet contains source-routing metadata; it ensures all fragments carry the same route field, preventing the fallback to flooding that would occur if Version 1 were used for routed packets. This distinction is critical for maintaining path constraints across fragmented transmissions.
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 →