How BitChat Handles BLE Packet Fragmentation and Reassembly for Large Messages
BitChat splits messages exceeding the BLE MTU (~512 bytes) into fragments with 8-byte IDs, stores them in a capped assembly buffer, and reassembles them in-order before re-injecting complete packets into the normal receive pipeline.
When sending large data over Bluetooth Low Energy, developers hit the MTU ceiling quickly. The open-source BitChat protocol solves this with a purpose-built fragmentation and reassembly system implemented across three core Swift components. This article examines exactly how BLEFragmentAssemblyBuffer.swift and BLEFragmentHandler.swift work together to reconstruct multi-packet messages reliably.
The Fragment Header Structure
Every fragment begins with a 13-byte minimum header parsed by BLEFragmentHeader in [BLEFragmentAssemblyBuffer.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift#L9-L53). The header extractor pulls:
- Sender ID (identifies the source peer)
- 8-byte fragment ID (unique per message)
- Fragment index and total count (for ordering)
- Original packet type (preserved for reassembly)
- Broadcast flag (triggers gossip sync tracking)
- Fragment payload
// Extract header from incoming BLE packet
if let header = BLEFragmentHeader(packet: incomingPacket) {
// header.id, header.index, header.total, header.isBroadcast now available
}
The header validation enforces the minimum size constraint. Packets that fail this check are treated as non-fragments and bypass the assembly pipeline.
The Assembly Buffer: Storage and Eviction
BLEFragmentAssemblyBuffer manages concurrent fragment collections using a composite key of sender + fragmentID. Located in [BLEFragmentAssemblyBuffer.swift (lines 56-135)](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/BLE/BLEFragmentAssemblyBuffer.swift#L56-L135), the buffer implements several protective mechanisms:
Size Limits and Eviction
The buffer enforces per-type size limits via assemblyLimit(for:). When append is called:
startAssemblyIfNeededcreates a new entry for unseen keys- If
maxInFlightAssembliesis exceeded, the oldest assembly is evicted - Projected total size is checked against the type limit
- Oversized assemblies return
.oversizedand are discarded
The Three Append Results
let result = buffer.append(header, maxInFlightAssemblies: 8)
switch result {
case .stored(let hdr, let started):
// Fragment cached, waiting for more pieces
case .complete(let hdr, let data, _):
// All fragments received—data contains reassembled bytes
case .oversized(let hdr, let projected, let limit, _):
// Assembly aborted, would exceed size limits
}
Out-of-Order Handling
Fragments are stored in a dictionary keyed by index: fragmentsByKey[key][index]. This allows arrival in any order—the buffer only requires complete index coverage before returning .complete.
Fragment Handler: Orchestration and Re-Injection
BLEFragmentHandler in [BLEFragmentHandler.swift (lines 24-76)](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/BLE/BLEFragmentHandler.swift#L24-L76) serves as the entry point for all incoming BitchatPacket instances. Its responsibilities include:
Self-Fragment Suppression
When the sender matches the local peer ID, fragments are tracked for gossip sync but not assembled. This prevents a device from inadvertently reassembling its own data after app relaunch.
Broadcast Tracking
Broadcast fragments trigger trackPacketSeen calls. This enables gossip sync recovery—later rounds can detect missing fragments and request them specifically.
Reassembly Completion Flow
When the buffer returns .complete:
// Inside BLEFragmentHandler.handle(_:from:)
case .complete(let hdr, let reassembledData, _):
// 1. Decode the raw bytes back into a packet
let packet = try BinaryProtocol.decode(reassembledData)
// 2. Validate the inner sender
guard isAcceptedIngressPayload(packet) else { return }
// 3. Zero the TTL for fresh propagation
packet.ttl = 0
// 4. Re-inject into normal receive pipeline
processReassembledPacket(packet)
This zero-TTL reset ensures reassembled packets get full propagation lifetimes rather than inheriting degraded TTL from their fragment journey.
Stalled Broadcast Recovery
Broadcast fragments carry time-to-complete risk—peers may drop offline mid-transfer. The buffer detects stalled assemblies through timestamp tracking:
let stalledIDs = buffer.stalledBroadcastFragmentIDs(
stalledAfter: 5, // No progress for 5 seconds
retryAfter: 10 // Wait 10 seconds between re-requests
)
// Encode stalledIDs into REQUEST_SYNC to recover missing fragments
Each new fragment index refreshes the "last fragment" timestamp. Assemblies with stale timestamps are flagged for selective re-requesting rather than blind retransmission.
Complete Fragment Processing Example
import Foundation
// Configure buffer with conservative limits
var buffer = BLEFragmentAssemblyBuffer()
let maxConcurrent = 8
let sizeLimit: UInt32 = 1024 * 1024 // 1MB per message type
func handleBLEPacket(_ packet: BitchatPacket, from sender: PeerID) {
// Skip non-fragments immediately
guard packet.isFragment else {
processDirectPacket(packet)
return
}
// Extract and validate header
guard let header = BLEFragmentHeader(packet: packet) else {
logInvalidFragment(packet)
return
}
// Route through buffer
let result = buffer.append(header, maxInFlightAssemblies: maxConcurrent)
logFragmentAssemblyResult(result, header: header)
switch result {
case .complete(let hdr, let data, _):
do {
let reassembled = try BinaryProtocol.decode(data)
validateAndProcess(reassembled, originalHeader: hdr)
} catch {
logDecodeFailure(error, fragmentID: hdr.idLogString)
}
case .oversized(let hdr, let projected, let limit, _):
reportOversizedDrop(sender: hdr.sender,
fragmentID: hdr.id,
projected: projected,
limit: limit)
case .stored:
break // Normal fragmentation in progress
}
}
// Periodic stalled broadcast check
Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
let stalled = buffer.stalledBroadcastFragmentIDs(
stalledAfter: 5,
retryAfter: 10
)
if !stalled.isEmpty {
broadcastSyncRequest(fragmentIDs: stalled)
}
}
Summary
BitChat's BLE packet fragmentation and reassembly system handles large message transport through:
- Structured fragment headers with 8-byte unique IDs and explicit indexing
- Capped assembly buffer with LRU eviction and per-type size limits
- Out-of-order tolerance via indexed fragment storage
- Self-fragment suppression preventing local echo reassembly
- Broadcast tracking enabling gossip sync recovery
- Stalled assembly detection with selective re-request logic
- TTL reset on completion ensuring full propagation lifetimes
The implementation in permissionlesstech/bitchat demonstrates production-ready Bluetooth Low Energy messaging without assuming reliable in-order delivery.
Frequently Asked Questions
What is the maximum message size BitChat can reassemble?
The buffer enforces per-packet-type limits via assemblyLimit(for:). While the analysis shows 1MB typical configurations, specific limits depend on the OriginalPacketType of the fragmented message. Oversized assemblies return .oversized and are dropped rather than risking memory exhaustion.
How does BitChat prevent memory exhaustion from partial fragments?
Three mechanisms protect against memory attacks: a configurable cap on concurrent assemblies (maxInFlightAssemblies), per-type size projection checks before accepting new fragments, and LRU eviction of oldest assemblies when capacity is exceeded. These are implemented in BLEFragmentAssemblyBuffer.startAssemblyIfNeeded and the size validation logic.
Can fragments arrive out of order and still reassemble correctly?
Yes. The fragmentsByKey dictionary stores fragments keyed by their index within the message. The buffer tracks which indices have been received and only returns .complete when the received count equals header.total. No sequence number validation beyond index bounds checking is performed.
What happens if a peer disappears mid-broadcast?
Broadcast fragments trigger timestamp tracking. The stalledBroadcastFragmentIDs method identifies assemblies with no new fragment arrivals within stalledAfter seconds. These IDs can be encoded into REQUEST_SYNC packets to request retransmission of specific missing fragments, scoped by the retryAfter cooldown to prevent sync spam.
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 →