TEJVON
Back to Engineering Impact
BLE Engineering 11 min readJune 2026

Custom Fragmentation and Reassembly Over GATT: A Production Protocol Design

How to transmit large payloads reliably over BLE when the standard MTU is not enough — with sequence integrity and per-chunk CRC.

Summary

BLE's Maximum Transmission Unit limits usable payload to 20–509 bytes per packet. For firmware images, configuration blobs, or large data transfers, you need a custom fragmentation protocol. This is how I designed one for an automotive connected device system.

T

Surendra — TEJVON

Senior Android & BLE Engineer

The Bluetooth Core Specification guarantees delivery of individual packets — but says nothing about payload size limits beyond MTU. For a wearable sending a 5KB firmware update or an automotive device receiving a 64KB configuration payload over BLE, you cannot fit the data in a single ATT write. You need fragmentation.

What follows is the exact fragmentation and reassembly protocol I designed for a production automotive connected-device system where firmware updates were required in the field, over BLE, on vehicles that couldn't be plugged into a computer. The constraints were severe: 20-byte MTU floor (some legacy devices never negotiated higher), unreliable RF environment, devices that could lose power mid-update.

Why BLE Fragmentation Is Not Trivial

GATT Write operations are fire-and-forget by default. Write Without Response is faster but gives no delivery confirmation. Write With Response (Write Request) confirms the packet reached the device's ATT layer — but not your application layer. You need sequence tracking, integrity verification, and a reassembly buffer on the receiver. You need a retransmission mechanism. And you need to handle partial transfers gracefully.

Production Insight

ATT Write With Response confirms delivery to the device's ATT layer only. Your device firmware can accept the packet at the ATT layer and still discard or corrupt it at the application layer. CRC validation is your responsibility.

Packet Structure Design

Every fragment carries enough information for the receiver to: identify its position in the sequence, know when the transfer is complete, and validate its integrity independently. Our packet header uses 6 bytes of overhead:

Packet structure — 6 byte header + payload + 2 byte CRCtext
┌─────────────┬─────────────┬─────────────┬───────────────────┬──────────┐
│  SEQ_NUM    │TOTAL_CHUNKS │  PAY_LEN    │     PAYLOAD       │  CRC-16  │
│  2 bytes    │  2 bytes    │  2 bytes    │    N bytes        │  2 bytes │
└─────────────┴─────────────┴─────────────┴───────────────────┴──────────┘

Total packet size = 8 + N bytes
For MTU=23: N = 23 - 3 (ATT header) - 8 = 12 bytes payload
For MTU=247: N = 247 - 3 - 8 = 236 bytes payload

Kotlin Implementation: Fragmenter

BleFragmenter — splits ByteArray into protocol packetskotlin
object Crc16 {
    private const val POLY = 0x1021 // CRC-16/CCITT-FALSE
    fun calculate(data: ByteArray): Int {
        var crc = 0xFFFF
        for (byte in data) {
            crc = crc xor (byte.toInt() and 0xFF shl 8)
            repeat(8) {
                crc = if (crc and 0x8000 != 0) (crc shl 1) xor POLY else crc shl 1
            }
        }
        return crc and 0xFFFF
    }
}

data class BleFragment(
    val seqNumber: Int,
    val totalChunks: Int,
    val payload: ByteArray,
    val crc: Int
) {
    fun toByteArray(): ByteArray {
        val buffer = ByteBuffer.allocate(6 + payload.size + 2)
            .order(ByteOrder.LITTLE_ENDIAN)
        buffer.putShort(seqNumber.toShort())
        buffer.putShort(totalChunks.toShort())
        buffer.putShort(payload.size.toShort())
        buffer.put(payload)
        buffer.putShort(crc.toShort())
        return buffer.array()
    }
}

class BleFragmenter(private val negotiatedMtu: Int) {
    // ATT overhead = 3 bytes (opcode + handle)
    // Header overhead = 6 bytes (SEQ + TOTAL + LEN)
    // CRC overhead = 2 bytes
    val maxPayload = negotiatedMtu - 3 - 6 - 2

    fun fragment(data: ByteArray): List<BleFragment> {
        require(maxPayload > 0) { "MTU too small for fragmentation header" }
        val chunks = data.toList().chunked(maxPayload)
        return chunks.mapIndexed { index, chunk ->
            val payload = chunk.toByteArray()
            BleFragment(
                seqNumber = index,
                totalChunks = chunks.size,
                payload = payload,
                crc = Crc16.calculate(payload)
            )
        }
    }
}

Kotlin Implementation: Reassembler

BleReassembler — receives fragments and rebuilds the original payloadkotlin
sealed class ReassemblyResult {
    data class Incomplete(val received: Int, val total: Int) : ReassemblyResult()
    data class Complete(val data: ByteArray) : ReassemblyResult()
    data class Error(val reason: String, val seqNumber: Int) : ReassemblyResult()
}

class BleReassembler {
    private val buffer = mutableMapOf<Int, ByteArray>()
    private var expectedTotal: Int = -1

    fun receive(rawPacket: ByteArray): ReassemblyResult {
        if (rawPacket.size < 8) {
            return ReassemblyResult.Error("Packet too short: ${rawPacket.size}", -1)
        }

        val bb = ByteBuffer.wrap(rawPacket).order(ByteOrder.LITTLE_ENDIAN)
        val seq = bb.short.toInt() and 0xFFFF
        val total = bb.short.toInt() and 0xFFFF
        val payLen = bb.short.toInt() and 0xFFFF
        val payload = ByteArray(payLen).also { bb.get(it) }
        val receivedCrc = bb.short.toInt() and 0xFFFF
        val computedCrc = Crc16.calculate(payload)

        // CRC mismatch — request retransmit of this chunk
        if (computedCrc != receivedCrc) {
            return ReassemblyResult.Error(
                "CRC mismatch on seq $seq: expected $computedCrc got $receivedCrc", seq
            )
        }

        if (expectedTotal == -1) expectedTotal = total
        if (total != expectedTotal) {
            return ReassemblyResult.Error("Total mismatch: $total vs $expectedTotal", seq)
        }

        buffer[seq] = payload

        return if (buffer.size == expectedTotal) {
            val assembled = (0 until expectedTotal)
                .map { buffer[it] ?: return ReassemblyResult.Error("Missing chunk $it", it) }
                .reduce { acc, bytes -> acc + bytes }
            buffer.clear()
            expectedTotal = -1
            ReassemblyResult.Complete(assembled)
        } else {
            ReassemblyResult.Incomplete(buffer.size, expectedTotal)
        }
    }

    fun reset() {
        buffer.clear()
        expectedTotal = -1
    }
}

Handling Partial Transfers and Power Loss

In production automotive environments, power loss mid-transfer is a real scenario. The device might lose power at chunk 47 of 200. When it powers back up, you need to resume from chunk 47 — not restart from 0. We solved this with a handshake characteristic: after each chunk the device writes back its last successfully received sequence number. On reconnect, we read this characteristic and resume from that point.

Production Result

This protocol transmitted firmware images up to 256KB over BLE to automotive devices in RF-noisy environments with zero corruption incidents in 18 months of production deployment. Transfer time for a 64KB image at MTU=247: approximately 22 seconds.

Topics

BLEGATTProtocol DesignFragmentationAndroidKotlinFirmware

Working on a similar challenge?

Let's discuss the architecture before you build.

BLE system design, OTA reliability, and connected product engineering — this is what TEJVON does every day.

Book a Technical Consultation