Once a BLE connection is established, the real challenge begins: reliably transmitting commands, handling fragmented responses, managing timeouts, and preventing race conditions. This post focuses on the data transmission layer, the architecture that turns raw bytes into a clean, reliable API.
💡 What You’ll Learn?
Build a bulletproof BLE command queue that handles fragmented responses, prevents race conditions, manages timeouts, and keeps your app responsive.
Part 1: The Five Challenges
BLE communication faces critical problems that naive implementations ignore:
🔴 Challenge 1: Sequential Execution
BLE devices process one command at a time. Concurrent writes cause byte interleaving → device receives garbage.
Thread A: write([0x10, 0x00])
Thread B: write([0x20, 0x00])
↓
Device receives: [0x10, 0x20, 0x10, 0x20] ❌
Solution: A lock ensuring only one command writes at a time.
🟣 Challenge 2: Fragmented Responses
Large responses split across multiple BLE packets (23–512 bytes each). App must buffer and reassemble.
write([0x10, 0x00]) ← Query data log
↓
Device: [0x01, 0x02, 0x03, ...] ← Packet 1
[0x04, 0x05, 0x06, ...] ← Packet 2
[0xFF] ← End marker
Solution: Handlers that decide: “Is response complete or do I need more packets?”
🟢 Challenge 3: Response Correlation
Without tracking which command awaits which response, packets meant for Command A get parsed by Command B.
Solution: Only one command executes at a time. Tight packet validation ensures packets belong to current command.
🟡 Challenge 4: Timeout Without Deadlock
Device stops responding → app waits forever unless timeouts prevent it.
Solution: Start timeout on write, restart after each packet, fail gracefully if device unresponsive.
🔵 Challenge 5: Asynchronous Notifications
Device sends unsolicited packets: real-time heart rate, battery status, charging state, device events. These don’t match current command execution.
Solution: Route asynchronous notifications to separate broadcast stream. App can listen independently while command queue remains focused on request-response cycles.
// Command queue processes request-response
final response = await queue.enqueueCommand(...);
// Meanwhile, device sends asynchronous notifications
// Heart rate: [0x78] ← Doesn't match current command
// Battery: [0x2A] ← Doesn't match current command
// These are broadcast to separate listener:
queue.unhandledPackets.listen((packet) {
handleAsyncNotification(packet.bytes); // Route to listeners
});
Part 2: BLE Physical Transport Layer
Most BLE devices use two characteristics for request-response communication:
Service: 0000fff0-0000-1000-8000-00805f9b34fb
├─ Write Char (0000fff6) → Commands: App → Device
└─ Notify Char (0000fff7) → Responses: Device → App
How Writes Work?
App sends command bytes to write characteristic:
// Send command to device
await characteristic.write(
[0x10, 0x00], // Battery query
withoutResponse: false, // Wait for device ACK
);
How No
How Notifications Work?
Device sends response bytes via notify characteristic. App subscribes and listens:
// Enable notifications
await characteristic.setNotifyValue(true);
// Receive all responses from device
characteristic.lastValueStream.listen((packet) {
commandQueue.handleIncomingPacket(packet);
});
Complete Request-Response Cycle

Key insight: Write and Notify are independent channels:
- Device pushes unsolicited notifications anytime — Heart rate, battery status, device events stream independently (no write needed)
- Every write gets a response — Commands always return one or more notification packets
- One write can trigger multiple notifications — Data requests stream multiple packets until complete
The command queue must handle both:
- Request-response cycles: Write command → Wait for notification(s)
- Asynchronous streams: Device sends notifications anytime, whether you requested or not
Part 3: The Command Queue Architecture

The queue ensures only one command executes at a time while handling all the complexity:
Core Implementation
class CommandQueue {
final CommandWriter commandWriter;
final _lock = Lock(); // Synchronized lock
CommandExecution? _currentExecution;
final _timeout = _TimeoutController();
Future<List<T>> enqueueCommand<T>(
Command command,
ResponseHandler<T> handler, {
Duration timeout = const Duration(seconds: 10),
}) {
return _lock.synchronized(() async {
// 1. Create execution for this command
final execution = handler.createExecution(command);
_currentExecution = execution;
// 2. Send command bytes to device
await commandWriter(command.bytes);
// 3. Start timeout timer
_timeout.start(execution, timeout: timeout);
try {
// 4. Wait for response(s) to complete
return await execution.completer.future;
} finally {
// 5. Cleanup
_timeout.cancel();
execution.dispose();
_currentExecution = null;
}
});
}
void handleIncomingPacket(List<int> packet) async {
final execution = _currentExecution;
if (execution == null) return; // No command waiting
if (execution.completer.isCompleted) return; // Already done
try {
await execution.handlePacket(packet);
if (!execution.completer.isCompleted) {
_timeout.start(execution); // Restart for next packet
}
} catch (e) {
execution.completer.completeError(e);
}
}
}
Execution Timeline

Read this: Flutter BLE Deep Dive: Platform Channels for Native Bluetooth Integration
Part 4: Response Handlers
Different commands expect different response patterns. Handlers encapsulate this logic:
Single Response Handler
Expects exactly one packet response:
final battery = await queue.enqueueCommand<int>(
Command([0x10, 0x00]), // Battery query
SingleResponseHandler(
parser: (packet) => packet[0], // First byte = battery %
),
);
Completes immediately after first valid packet arrives.
Multi-Response Handler
Expects multiple packets, decides when complete:
final samples = await queue.enqueueCommand<int>(
Command([0x20, 0x00]), // Start data stream
MultiResponseHandler<int>(
parser: (packet) => packet[0], // Each byte is a sample
decideCompletion: (value, packetCount) {
if (value == 0xFF) { // End marker?
return PacketDecision.complete();
} else if (packetCount >= 100) { // Got enough?
return PacketDecision.complete();
} else {
return PacketDecision.resume([0x21]); // Ask for more
}
},
),
);
Accumulates all packets, returns complete list when done.
Packet Validation State Machine

Part 5: Lock-Based Serialization
The lock ensures commands never interleave:
Without Lock (Corruption):
Thread A: write([0x10])
Thread B: write([0x20]) ← Concurrent writes
↓
Device receives: [0x10, 0x20, 0x10, 0x20, ...] ❌
With Lock (Sequential):

Part 6: Timeout Management
Timeouts prevent deadlocks when devices don’t respond:
class _TimeoutController {
Timer? _timer;
void start(
CommandExecution execution, {
Duration timeout = const Duration(seconds: 10),
}) {
_timer?.cancel();
_timer = Timer(timeout, () {
if (!execution.completer.isCompleted) {
execution.completer.completeError(
TimeoutException('Device did not respond within $timeout'),
);
}
});
}
void cancel() {
_timer?.cancel();
_timer = null;
}
}
Three scenarios:
| Scenario | Behavior |
|---|---|
| Device responds fast | ✓ Timeout cancelled, completes normally |
| Device offline | ✗ Timeout fires after 10s, fails command |
| Multi-packet (slow) | ✓ Timeout restarts after each packet, no false positives |
Part 7: Public API
Wrap the queue in a clean interface:
class DataTransmissionLayer {
final CommandQueue _queue;
Future<T> sendSingleResponseCommand<T>(
List<int> bytes,
T Function(List<int>) parser, {
Duration timeout = const Duration(seconds: 10),
}) async {
final responses = await _queue.enqueueCommand<T>(
Command(bytes),
SingleResponseHandler(parser: parser),
timeout: timeout,
);
return responses.first;
}
Future<List<T>> sendMultiResponseCommand<T>(
List<int> bytes,
T Function(List<int>) parser, {
required PacketDecisionMaker<T> decideCompletion,
Duration timeout = const Duration(seconds: 10),
}) =>
_queue.enqueueCommand<T>(
Command(bytes),
MultiResponseHandler(
parser: parser,
decideCompletion: decideCompletion,
),
timeout: timeout,
);
Stream<UnhandledPacket> get unhandledPackets => _queue.unhandledPackets;
}
Usage:
// Single query
final battery = await layer.sendSingleResponseCommand<int>(
[0x10, 0x00],
(packet) => packet[0],
);
print('Battery: $battery%');
// Data stream
final samples = await layer.sendMultiResponseCommand<int>(
[0x20, 0x00],
(packet) => packet[0],
decideCompletion: (value, count) =>
value == 0xFF
? PacketDecision.complete()
: PacketDecision.resume([0x21]),
);
print('Collected ${samples.length} samples');
Key Takeaways
✅ Build Robust BLE Communication with:
- 🔒 Lock: Serialize writes to prevent byte corruption
- 📦 Handlers: Support single and multi-packet response patterns
- ⏱️ Timeout: Prevent deadlocks, restart per packet
- 📡 Validation: Route packets only to current command
- 📢 Broadcasting: Capture unexpected packets for debugging
This architecture scales to any BLE device protocol while preventing the common pitfalls of device communication that plague naive implementations.