The symptom is specific and misleading: the device advertises, the client connects, the connection stays up, and the receiver reports that bytes are arriving. But no complete record ever appears. Over USB the identical firmware works perfectly.
The cause is almost always the ATT MTU, and it catches people because nothing in the stack reports an error.
What is actually happening
BLE negotiates a Maximum Transmission Unit per connection. The default is 23 bytes, of which 3 are ATT protocol overhead — so you get 20 bytes of payload per notification unless something raises it.
If you are streaming newline-delimited data, that limit lands in the middle of your records. A line like this is 46 bytes:
{"pitch":12.3,"roll":-4.5,"yaw":180.1}It goes out as two or three notifications. A receiver that buffers until it sees a newline will keep buffering, because the newline arrives in a later packet that the naive write path may never send — or sends in a way the client reassembles incorrectly. Bytes are flowing. Records never complete. Nothing errors.
The fix, in two parts
On a Nordic nRF52 using the Adafruit Bluefruit stack, raise the peripheral bandwidth so a larger MTU is negotiated:
// Before Bluefruit.begin()
Bluefruit.configPrphBandwidth(BANDWIDTH_MAX);Then stop assuming a single write reaches the other side intact. Frame the line explicitly and send it in MTU-sized chunks, terminating the record yourself:
void bleSendLine(const char *s) {
size_t len = strlen(s);
size_t mtu = Bluefruit.Connection(0)->getMtu() - 3; // ATT overhead
for (size_t off = 0; off < len; off += mtu) {
size_t n = min(mtu, len - off);
bleuart.write((const uint8_t *)s + off, n);
}
bleuart.write((const uint8_t *)"\n", 1);
}The receiving side must reassemble rather than treat each notification as a message. In the browser, that means accumulating into a buffer and splitting on the newline, not parsing each event:
let buf = "";
characteristic.addEventListener("characteristicvaluechanged", (e) => {
buf += decoder.decode(e.target.value);
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (line) handle(JSON.parse(line));
}
});Two adjacent problems that look identical
The OS already holds the connection
On Windows, pairing the device in Settings → Bluetooth & devices can leave the OS holding it. The page then connects, reports a live connection, and receives nothing. Remove the device from Windows settings and connect only through the application. The tell is that it fails identically after a reboot, which a genuine MTU problem also does — so rule this out first, it is faster.
Something else owns the serial port
Only one program can hold a COM port. An open Arduino Serial Monitor will block a browser page from opening the same port, and the failure surfaces as a permissions error rather than anything informative. Close the monitor.
How to tell these apart in 30 seconds
- Connect over USB instead. If records parse fine there, the firmware and the format are correct and the problem is the BLE link.
- Log the raw string the client receives, before parsing. Truncated JSON that stops mid-token is the MTU signature.
- Print the negotiated MTU on connect. If it is 23, nothing raised it.
- Check the OS Bluetooth settings for an existing pairing before assuming a code problem.
The general lesson is that BLE gives you a stream of packets, not a stream of bytes. Any protocol with record delimiters has to reassemble on the receiving side, and any firmware sending them has to frame explicitly. Serial lets you get away with pretending otherwise, which is exactly why the bug appears only after you go wireless.