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:

  1. startAssemblyIfNeeded creates a new entry for unseen keys
  2. If maxInFlightAssemblies is exceeded, the oldest assembly is evicted
  3. Projected total size is checked against the type limit
  4. Oversized assemblies return .oversized and 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:

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 →