TL;DR: In Flutter BLE, you read data with
characteristic.read(), write withcharacteristic.write([bytes]), and stream real-time updates withcharacteristic.setNotifyValue(true)+lastValueStream. Always checkcharacteristic.propertiesbefore operating, handle timeouts, and parse raw bytes carefully. This guide covers every data operation you need in production.
Flutter BLE Read & Write Characteristics: Complete Guide with flutter_blue_plus
Once you've scanned, connected, and discovered services, the real work begins: reading sensor data, sending commands, and subscribing to real-time updates. BLE characteristics are the data containers you'll interact with constantly. This guide covers every read/write/notify pattern, data parsing technique, and error handling strategy you need.
Prerequisites
Before reading or writing, you need a connected device with discovered services:
- Getting Started with BLE in Flutter — BLE fundamentals
- Flutter BLE Scanning Guide — find and connect to devices
- BLE GATT Profiles Explained — understand services and characteristics
Finding the Right Characteristic
Always find characteristics by UUID rather than index:
BluetoothCharacteristic? findChar(
List<BluetoothService> services,
String serviceUuid,
String charUuid,
) {
for (final svc in services) {
if (svc.uuid.str128.toLowerCase() == serviceUuid.toLowerCase()) {
for (final c in svc.characteristics) {
if (c.uuid.str128.toLowerCase() == charUuid.toLowerCase()) return c;
}
}
}
return null;
}
final services = await device.discoverServices();
final char = findChar(services, SERVICE_UUID, CHAR_UUID);
Reading Characteristics
if (char.properties.read) {
final List<int> bytes = await char.read().timeout(const Duration(seconds: 5));
print('Raw bytes: $bytes');
}
Parsing Raw Bytes
List<int> bytes = await char.read();
// String
String text = String.fromCharCodes(bytes);
// Little-endian integers (most common in BLE)
int uint8 = bytes[0];
int uint16 = bytes[0] | (bytes[1] << 8);
int uint32 = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24);
// Floating point
ByteData bd = ByteData.sublistView(Uint8List.fromList(bytes));
double temperature = bd.getFloat32(0, Endian.little);
// Bit flags (status byte)
bool isRunning = (bytes[0] & 0x01) != 0;
bool hasError = (bytes[0] & 0x02) != 0;
bool isBatLow = (bytes[0] & 0x04) != 0;
// Heart rate (standard BLE format)
bool is16bit = (bytes[0] & 0x01) != 0;
int heartRate = is16bit ? bytes[1] | (bytes[2] << 8) : bytes[1];
Writing Characteristics
Write With Response
if (char.properties.write) {
await char.write([0x01, 0x02], withoutResponse: false);
}
Write Without Response (Higher Throughput)
if (char.properties.writeWithoutResponse) {
await char.write([0xFF], withoutResponse: true);
}
Building Payloads
// String command
await char.write('SET_MODE:1'.codeUnits);
// Structured binary command
List<int> buildCmd(int cmdId, int param) => [
cmdId,
param & 0xFF,
(param >> 8) & 0xFF,
];
await char.write(buildCmd(0x10, 1000));
// Float payload
final bd = ByteData(4)..setFloat32(0, 98.6, Endian.little);
await char.write(bd.buffer.asUint8List());
Chunked Write for Large Data
BLE MTU limits how much you can send at once (default 20 bytes, up to 512 with negotiation):
Future<void> writeChunked(BluetoothCharacteristic char, List<int> data) async {
final mtu = (await char.device.mtu.first) - 3; // subtract ATT header
for (int i = 0; i < data.length; i += mtu) {
final chunk = data.sublist(i, (i + mtu).clamp(0, data.length));
await char.write(chunk, withoutResponse: true);
await Future.delayed(const Duration(milliseconds: 10));
}
}
// First negotiate a larger MTU
await device.requestMtu(512);
await writeChunked(txChar, firmwareBytes);
Subscribing to Notifications (Real-Time Data)
Notifications let the device push data to you — far more efficient than polling.
if (char.properties.notify) {
await char.setNotifyValue(true);
final sub = char.lastValueStream.listen((bytes) {
if (bytes.isEmpty) return;
final reading = parseSensorData(bytes);
updateUI(reading);
});
// Later: clean up
await char.setNotifyValue(false);
await sub.cancel();
}
Notifications vs Polling
| Approach | Battery | Latency | Use When |
|---|---|---|---|
| Notify | Low | Immediate | Device supports it (preferred) |
| Poll (repeated read) | High | On-demand | No notify support |
| Indicate | Low | Reliable ACK | Critical commands |
Always prefer notifications — polling constantly wakes the radio and drains battery fast.
Managing Notification State in a Widget
class SensorPage extends StatefulWidget {
final BluetoothDevice device;
const SensorPage({required this.device});
@override
State<SensorPage> createState() => _SensorPageState();
}
class _SensorPageState extends State<SensorPage> {
BluetoothCharacteristic? _char;
StreamSubscription? _sub;
double _temperature = 0;
@override
void initState() {
super.initState();
_setup();
}
Future<void> _setup() async {
final services = await widget.device.discoverServices();
_char = findChar(services, TEMP_SERVICE, TEMP_CHAR);
if (_char == null) return;
await _char!.setNotifyValue(true);
_sub = _char!.lastValueStream.listen((bytes) {
setState(() => _temperature = parseTemp(bytes));
});
}
double parseTemp(List<int> bytes) {
if (bytes.length < 2) return 0;
return bytes[0] + bytes[1] / 100;
}
@override
void dispose() {
_sub?.cancel();
_char?.setNotifyValue(false);
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
body: Center(child: Text('${_temperature.toStringAsFixed(1)}°C',
style: const TextStyle(fontSize: 48))),
);
}
Error Handling
Future<List<int>?> safeRead(BluetoothCharacteristic char) async {
try {
if (!char.properties.read) return null;
return await char.read().timeout(const Duration(seconds: 5));
} on TimeoutException {
print('Read timed out — device may be busy');
return null;
} on FlutterBluePlusException catch (e) {
print('BLE error ${e.errorCode}: ${e.description}');
return null;
}
}
| Error Code | Meaning | Action |
|---|---|---|
| 133 | GATT_ERROR | Retry after short delay |
| 8 | Connection timeout | Check device proximity |
| 19 | Peer terminated | Re-scan and reconnect |
| 257 | Not connected | Connect first |
Related Guides
- 🚀 Getting Started with BLE in Flutter — BLE foundations
- 🔬 BLE GATT Profiles Explained — Services & characteristics hierarchy
- 📡 Flutter BLE Scanning & Discovery — Find devices first
- 🔒 Flutter BLE Permissions: Android & iOS — Permission setup
- 🏗️ Build a Complete Flutter BLE App — Everything together
- 📦 Flutter BLE Packages Comparison — Package choices
- 🔄 flutter_blue vs flutter_blue_plus — Package migration
- ⚡ BLE vs Classic Bluetooth in Flutter — Protocol comparison
- 🤖 ESP32 vs Arduino for Flutter BLE — Hardware pairing
- ⚖️ Flutter vs React Native for BLE — Framework comparison
- 📱 Flutter BLE vs Native Android (Kotlin) — vs native
- 🌐 BLE vs WiFi for Flutter IoT — Connectivity comparison
Frequently Asked Questions
Why does characteristic.read() return an empty list?
This usually means: (1) the characteristic doesn't support read — always check properties.read first, (2) the device hasn't populated the value yet — try a short delay after connection, or (3) you need to write a trigger command first before the device fills the value.
What's the difference between lastValueStream and onValueReceived?
lastValueStream replays the last known value to new subscribers and emits on both reads and notifications — ideal for UI binding. onValueReceived only fires when new data arrives from the peripheral. Use lastValueStream in most cases.
How do I send a large file over BLE?
First call device.requestMtu(512) to negotiate the largest MTU possible, then split your payload into chunks of (mtu - 3) bytes and write sequentially with withoutResponse: true. Add small inter-chunk delays to avoid buffer overflow on the peripheral side.
Why does my notification stream stop after a while?
Most likely the device disconnected. Always listen to device.connectionState and re-subscribe to notifications after reconnecting. Also ensure you're storing your StreamSubscription in a field and not letting it get garbage collected.
Can I read and write simultaneously?
BLE ATT operations are serialized — flutter_blue_plus queues them internally. Don't try to fire parallel operations on the same characteristic; use sequential async/await chains.
What's the best way to learn BLE data operations with real devices?
The BLE Flutter Course teaches every read/write/notify pattern in this guide using actual BLE hardware — ESP32, Arduino, and commercial sensors — so theory immediately becomes working code.
Build Production BLE Apps
You now have everything needed for robust BLE data pipelines: reading sensor values, writing commands, streaming real-time notifications, handling errors, and transferring large payloads.
Next step: See Build a Complete Flutter BLE App to put every pattern together, or enroll in the BLE Flutter Course for structured hands-on learning.