BLE Notify on Photon 2 gets disrupted at 10 notifies

We have prototypes running on multiple platforms: BSOM, Boron, Argon and Photon 2. They all run pretty much the same code that gathers sensor records, stores them in memory ring buffer, publishes to cloud, and exposes BLE characteristics to respond to data dump requests. The BLE mode is peripheral. The characteristics look like this:

static BleCharacteristic commandWriteCharacteristic("Cmd", /*BleCharacteristicProperty::WRITE |*/ BleCharacteristicProperty::WRITE_WO_RSP, BleUuid("b088ef40-6c7c-4b82-b191-5376bd85a75f"), uuidEnvironmentalSensingService);

static BleCharacteristic commandResponseCharacteristic("CmdRsp", /*BleCharacteristicProperty::INDICATE |*/ BleCharacteristicProperty::NOTIFY, BleUuid("b088ef50-6c7c-4b82-b191-5376bd85a75f"), uuidEnvironmentalSensingService);

A Flutter app running on Andriod or iOS or MacOS subscribes to the NOTIFY characteristic, issues a command through the commandWriteCharacteristic and then stores / graphs the chunked records returned. Our code determines the MTU and then the maximum packet size (MTU-3) using:

		ssize_t iCurrentAttMtu = BLE.getCurrentAttMtu(peer);
		if (iCurrentAttMtu >= 23)
			uiPacketSize = (size_t)iCurrentAttMtu-3; // subtract 3 bytes for ATT header
		uiRecordsPerNotify = (unsigned int)(uiPacketSize / uiRecordSize);

This all works solid and reliably on BSOM, Boron, Argon - with any client (Android, iOS, MacOS). It fails on Photon 2 when the number of notifies required exceeds 10. For the various combinations of Particle devices / clients we see different values for uiPacketSize that have been negotiated between Flutter and Particle - varying from 184 bytes to 244 bytes.

We tried slowing down the Photon 2 with delay(), thinking it might be flooding the client with notifies too fast - didn't change the failure. Turned on { "wiring.ble", LOG_LEVEL_ALL} but that only showed us connects and disconnects. We tried hard coding the uiPacketSize rather than trying to determine it - and found we could break the system but not solve the problem.

So we instrumented our code with Log.info() statements to show us the write command coming in and notify packets sent out. There was no indication of a failure and in fact the logs showed all of the packets being sent to the client.

We turned to debug logs on the Flutter app which surfaces the problem. Here's the log on a successful write / notify request:

2026-08-11 12:49:20.697 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: setNotifyValue
2026-08-11 12:49:20.727 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateNotificationStateForCharacteristic:
2026-08-11 12:49:20.727 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
flutter: [history] AQ1-eef43212 request: {"TS":1786380560,"N":96,"RF":2}
flutter: [history] AQ1-eef43212 write mode: without-response
2026-08-11 12:49:20.762 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: writeCharacteristic
2026-08-11 12:49:20.837 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:20.837 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:20.878 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:20.878 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:20.912 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:20.912 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:20.942 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:20.942 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:20.972 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:20.972 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:21.002 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:21.002 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:21.042 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:21.043 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:21.089 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:21.089 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:21.122 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:21.122 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:21.197 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:21.197 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:21.225 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:49:21.225 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:49:21.227 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: setNotifyValue
2026-08-11 12:49:21.254 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateNotificationStateForCharacteristic:
2026-08-11 12:49:21.254 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f

We have the notify subscription, the write command, 11 notifies, and then the closing of the notify subscription.

The failing log:

2026-08-11 12:51:20.306 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: setNotifyValue
2026-08-11 12:51:20.334 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateNotificationStateForCharacteristic:
2026-08-11 12:51:20.334 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
flutter: [history] AQ1-4a065494 request: {"TS":1786380680,"N":96,"RF":2}
flutter: [history] AQ1-4a065494 write mode: without-response
2026-08-11 12:51:20.335 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: writeCharacteristic
2026-08-11 12:51:20.639 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:20.639 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:20.698 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:20.698 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:20.743 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:20.743 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:20.803 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:20.803 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:20.848 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:20.848 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:20.910 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:20.910 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:20.982 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:20.982 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:21.002 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:21.002 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:21.058 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:21.058 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 12:51:21.119 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 12:51:21.119 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
...waiting...timeout
2026-08-11 12:51:28.340 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: setNotifyValue
2026-08-11 12:51:28.360 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateNotificationStateForCharacteristic:
2026-08-11 12:51:28.360 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f

We have the same notify subscription, the write command, only 10 notifies followed by the timeout delay, and then the closing of the notify subscription. So even though our Photon 2 code shows it sends all the notifies, something happens underneath that chokes the process and the FLutter app never gets notify number 11 and times out.

Any experience out there with transferring larger chunks of data? Or have an idea for something else to log to determine the choke point?

SMALL UPDATE:

There is a bit of a clue if we change the write characteristic to be WRITE instead of WRITE_NO_RESPONSE. Here is the Flutter log:

2026-08-11 13:08:15.973 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: setNotifyValue
2026-08-11 13:08:16.000 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateNotificationStateForCharacteristic:
2026-08-11 13:08:16.000 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
flutter: [history] AQ1-4a065494 request: {"TS":1786381695,"N":96,"RF":2}
flutter: [history] AQ1-4a065494 write mode: with-response
2026-08-11 13:08:16.001 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: writeCharacteristic
2026-08-11 13:08:16.290 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.291 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.350 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.350 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.395 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.395 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.455 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.455 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.500 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.501 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.562 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.562 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.605 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.605 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.665 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.665 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.710 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.710 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.785 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateValueForCharacteristic:
2026-08-11 13:08:16.785 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f
2026-08-11 13:08:16.811 AQ1 Scan[3002:6957886] [FBP-iOS] didWriteValueForCharacteristic:
2026-08-11 13:08:16.811 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef40-6c7c-4b82-b191-5376bd85a75f
...waiting...timeout
2026-08-11 13:08:24.823 AQ1 Scan[3002:6957886] [FBP-iOS] handleMethodCall: setNotifyValue
2026-08-11 13:08:24.852 AQ1 Scan[3002:6957886] [FBP-iOS] didUpdateNotificationStateForCharacteristic:
2026-08-11 13:08:24.852 AQ1 Scan[3002:6957886] [FBP-iOS]   chr: b088ef50-6c7c-4b82-b191-5376bd85a75f

The beginning is the same, but right after the 10th didUpdateValueForCharacteristic, there is a didWriteValueForCharacteristic that references the write command characteristic - not the notify one. As if the Photon 2 was only then sending the response to the writing of the command (now that it is requested.) This log sequence is the same even when we put significant delays BEFORE the first notify and between notifies - always after 10 notifies something happens underneath that chokes the process.

Are you sending notifies inside of onDataReceived()? And if so, what if you try moving it outside of the handler and into the loop?

That was it! Too much going on in that thread on a Photon 2 (but not the other platforms apparently.) Moved the actual notifyCharacteristic.SetValue() into loop() and all is well.

Glad that worked!

In case you're interested, I had Claude investigate it a bit further and this is the root cause it came up with, which (if relevant) also explains why you didn't run into this issue with your nRF devices. I didn't compile this fix and test since I have nothing to run it against.

Root cause: BleGatt::notifyValue() skips flow control when the notify originates on the BLE event thread.

(Line numbers from develop)

  1. The onDataReceived handler runs on the BLE event thread. In hal/src/rtl872x/ble_hal.cpp:2868, gattWriteAttrCallback() invokes the app callback synchronously:
if (charact.callback) {
    ...
    charact.callback(&evt, charact.context);   // runs on bleEvtThread
}
  1. That makes notifyValue() fire-and-forget. At ble_hal.cpp:3204:
isNotifying_ = true;
CHECK_TRUE(server_send_data(...), SYSTEM_ERROR_INTERNAL);
if (BleEventDispatcher::getInstance().isThreadCurrent()) {
    isNotifying_ = false;
    break;                     // <-- no wait for PROFILE_EVT_SEND_DATA_COMPLETE
}
if (os_semaphore_take(notifySemaphore_, timeoutMs, false)) { ... }

Called from loop(), each notify blocks until the stack confirms the previous one — one packet in flight at a time. Called from the BLE event thread, that wait is skipped entirely. The skip is deliberate and correct in isolation: PROFILE_EVT_SEND_DATA_COMPLETE is dispatched by that same thread, so blocking there would deadlock. But nothing replaces the throttling it removes.

  1. The RTL872x stack flow-controls notifications with GAP TX credits, and Device OS never checks them. GAP_PARAM_LE_REMAIN_CREDITS is available (gap_le.h:70), and le_get_gap_param() is already used throughout this file — but across all of hal/src/rtl872x, the only occurrences of "credits" are two LOG_DEBUG format strings. The credit window is 10. Notify #11's server_send_data() returns false, notifyValue() returns SYSTEM_ERROR_INTERNAL (-250), and since almost nobody checks setValue()'s return value, the packet disappears without a trace.

  2. Why only the Photon 2. The nRF52840 HAL has no equivalent escape hatch — notifyValue() unconditionally blocks on hvxSemaphore_ after every sd_ble_gatts_hvx() (hal/src/nRF52840/ble_hal.cpp:2886). Boron/Argon/B-SoM self-throttle and can never exhaust TX buffers. The RTL872x path is the only one that can.

Corroborating detail: "when I switched the command characteristic to WRITE (with response), the Photon 2 sent the ATT write response only after all the notifies." That's consistent — the write callback hadn't returned yet, so the stack couldn't emit the response. It confirms the notify burst was running inside the callback on the BLE thread.

Workaround: copy the command in onDataReceived, set a flag, return immediately, and run the notify loop from loop(). The notifies then take the blocking path and credits never run out.

Suggested fix: make notifyValue() credit-aware. Blocking on notifySemaphore_ isn't an option on the event thread, but the credit counter is maintained by the Realtek stack task, so polling GAP_PARAM_LE_REMAIN_CREDITS is safe from either context. At minimum, server_send_data() returning false should surface as SYSTEM_ERROR_BUSY rather than a silent drop.

Yep - that all makes sense. But really confusing on this cross device development where the issue only shows on Photon 2. Thanks.