Binary Data and Byte Manipulation in Dart: TypedData, ByteBuffer, and Endianness Explained
While high-level applications often communicate using JSON or REST APIs, mission-critical systems and low-level integrations rely on raw binary data.
Whether you are:
- Communicating with hardware via Bluetooth Low Energy (BLE) or serial ports
- Parsing custom WebSocket or TCP binary wire protocols in real-time games
- Reading and modifying file headers (e.g. PNG, MP4, WAV, PDF)
- Slicing cryptographic hashes and keys
- Processing audio waveforms, sensor feeds, or raw camera pixel buffers
understanding Dart's dart:typed_data library is essential.
Using standard List<int> for binary data introduces severe performance overhead: each number in a standard Dart list requires 64-bit object wrapping. In contrast, TypedData provides contiguous, compact, unboxed memory blocks that interact directly with the operating system and hardware.
This guide explores the internal architecture of ByteBuffer, TypedData views, ByteData, endianness conversions (Endian.big vs Endian.little), bitwise masking, packet framing, and zero-copy binary streaming.
1. Why Not List<int>? The Power of TypedDataโ
In standard Dart code, a List<int> is a dynamic array of boxed integer references:
Standard List<int> (64-bit heap objects with pointer overhead):
List โโโบ [ Pointer โโโบ Integer Object (8 bytes + header) ]
โโโบ [ Pointer โโโบ Integer Object (8 bytes + header) ]
Contiguous Uint8List (Raw, unboxed flat memory buffer):
[ 0xFF | 0x00 | 0x4A | 0x12 | 0x8C | 0x33 ] (Exactly 1 byte per element!)
import 'dart:typed_data';
void main() {
// A compact block of 1,000,000 bytes takes exactly ~1MB of RAM
final rawBytes = Uint8List(1000000);
// Manipulated directly in contiguous memory
rawBytes[0] = 255;
print(rawBytes[0]); // 255
}
The benefits of TypedData:
- Contiguous Memory: Cache-friendly, $O(1)$ direct byte access.
- Fixed Bit-Widths: Guarantees values stay within target ranges (e.g.
Uint8wraps/clamps between0and255). - Zero-Copy Views: Multiple typed views can read from the exact same memory buffer without duplication.
2. The Triumvirate: ByteBuffer, TypedData Views, and ByteDataโ
The dart:typed_data architecture consists of three interconnected layers:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Underlying ByteBuffer (Raw Memory) โ
โ [ 0x00 ][ 0x01 ][ 0x00 ][ 0x02 ][ 0x40 ][ 0x49 ][ 0x0F ][ 0xDB ] โ
โโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโ
โ (View 1) โ (View 2) โ (View 3)
โผ โผ โผ
Uint8List (8 bytes) Uint16List (4 items) ByteData (Structured)
[0, 1, 0, 2, 64, 73, 15, 219] [256, 512, 18752, 56079] .getFloat32(4) -> 3.14159
1. ByteBuffer (The Storage)โ
The ByteBuffer represents the underlying contiguous byte array in memory. You rarely interact with it directly; instead, you wrap it in views.
2. TypedData Viewsโ
Typed arrays (Uint8List, Int16List, Float32List, Float64List, etc.) interpret the buffer as a sequence of fixed-size numbers:
import 'dart:typed_data';
void main() {
// Allocate a 4-byte buffer
final buffer = Uint8List(4).buffer;
// View as 8-bit unsigned integers:
final byteView = Uint8List.view(buffer);
byteView[0] = 0x12;
byteView[1] = 0x34;
byteView[2] = 0x56;
byteView[3] = 0x78;
// View the SAME memory as 16-bit integers without copying!
final uint16View = Uint16List.view(buffer);
print('16-bit view length: ${uint16View.length}'); // 2 elements
}
3. ByteData (Heterogeneous Slicing)โ
While Uint8List assumes every element has the same data type, ByteData allows you to read and write different data types at arbitrary byte offsets with explicit endianness control.
3. Endianness: Big-Endian vs. Little-Endianโ
When an integer occupies more than 1 byte (such as a 16-bit, 32-bit, or 64-bit integer), the bytes must be ordered in memory.
Consider the 32-bit hexadecimal value 0x12345678:
- Most Significant Byte (MSB):
0x12 - Least Significant Byte (LSB):
0x78
Memory Address: 0x00 0x01 0x02 0x03
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Big-Endian (Network): [0x12] [0x34] [0x56] [0x78] (MSB first - Natural reading order)
Little-Endian (x86/ARM):[0x78] [0x56] [0x34] [0x12] (LSB first - Standard on mobile/PC)
Endianness in Dartโ
Endian.big: Big-endian (standard for network protocols, TCP/IP headers, and BLE).Endian.little: Little-endian (standard on modern x86, ARM, and Apple Silicon CPUs).Endian.host: The native endianness of the processor running your Dart code.
import 'dart:typed_data';
void main() {
final data = ByteData(4);
// Write 0x12345678 as Big-Endian
data.setUint32(0, 0x12345678, Endian.big);
print(data.buffer.asUint8List()); // [18, 52, 86, 120] -> [0x12, 0x34, 0x56, 0x78]
// Write 0x12345678 as Little-Endian
data.setUint32(0, 0x12345678, Endian.little);
print(data.buffer.asUint8List()); // [120, 86, 52, 18] -> [0x78, 0x56, 0x34, 0x12]
}
Rule of Thumb: Always specify
Endian.bigorEndian.littleexplicitly when working with network packets or binary files. Never rely on the default host endianness for external protocols.
4. Reading & Writing with ByteDataโ
ByteData provides fine-grained getters and setters for all primitive types:
| Type | Size | Getter / Setter |
|---|---|---|
| 8-bit integer | 1 byte | getInt8() / getUint8() |
| 16-bit integer | 2 bytes | getInt16() / getUint16() |
| 32-bit integer | 4 bytes | getInt32() / getUint32() |
| 64-bit integer | 8 bytes | getInt64() / getUint64() |
| 32-bit Float | 4 bytes | getFloat32() / setFloat32() |
| 64-bit Double | 8 bytes | getFloat64() / setFloat64() |
import 'dart:typed_data';
void main() {
final buffer = ByteData(16); // 16 bytes total
// Offset 0: 2-byte header magic (0xCAFE)
buffer.setUint16(0, 0xCAFE, Endian.big);
// Offset 2: 1-byte status flag
buffer.setUint8(2, 1);
// Offset 3: 1-byte padding (0x00)
buffer.setUint8(3, 0);
// Offset 4: 4-byte float (temperature)
buffer.setFloat32(4, 98.6, Endian.big);
// Offset 8: 8-byte int (epoch timestamp ms)
buffer.setInt64(8, DateTime.now().millisecondsSinceEpoch, Endian.big);
// Read back values safely:
final magic = buffer.getUint16(0, Endian.big);
final temp = buffer.getFloat32(4, Endian.big);
print('Magic: 0x${magic.toRadixString(16).toUpperCase()}'); // 0xCAFE
print('Temp: ${temp.toStringAsFixed(1)}'); // 98.6
}
5. Real-World Case Study: Binary Packet Framing for IoT / Game Serversโ
Let's build a binary protocol frame encoder and parser for a sensor telemetry network packet.
Protocol Wire Format Specification:โ
โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ
โ Magic (2 Bytes) โ Ver (1B) โ Msg Type (1B) โ Payload Length (4 Bytes) โ Timestamp(8B)โ
โ 0xDEAD โ 0x01 โ 0x10 (Telemetry)โ uint32 (Big-Endian) โ int64 (ms) โ
โโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโค
โ Payload (N Bytes, UTF-8 or Binary) โ CRC16 (2B) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโ
Complete Packet Implementation:โ
import 'dart:convert';
import 'dart:typed_data';
class TelemetryPacket {
static const int magicNumber = 0xDEAD;
static const int headerSize = 16; // 2 + 1 + 1 + 4 + 8
final int version;
final int messageType;
final int timestamp;
final String payloadText;
TelemetryPacket({
this.version = 1,
required this.messageType,
required this.timestamp,
required this.payloadText,
});
/// Serializes packet to raw binary bytes
Uint8List toBinary() {
final payloadBytes = utf8.encode(payloadText);
final totalSize = headerSize + payloadBytes.length + 2; // +2 for CRC16
final byteData = ByteData(totalSize);
// 1. Magic (2 bytes)
byteData.setUint16(0, magicNumber, Endian.big);
// 2. Version (1 byte)
byteData.setUint8(2, version);
// 3. Message Type (1 byte)
byteData.setUint8(3, messageType);
// 4. Payload Length (4 bytes)
byteData.setUint32(4, payloadBytes.length, Endian.big);
// 5. Timestamp (8 bytes)
byteData.setInt64(8, timestamp, Endian.big);
// 6. Copy Payload Bytes
final resultBytes = byteData.buffer.asUint8List();
resultBytes.setRange(headerSize, headerSize + payloadBytes.length, payloadBytes);
// 7. Calculate and write simple CRC16 checksum at the end
final crc = _calculateChecksum(resultBytes.sublist(0, totalSize - 2));
byteData.setUint16(totalSize - 2, crc, Endian.big);
return resultBytes;
}
/// Deserializes raw binary bytes into a TelemetryPacket
factory TelemetryPacket.fromBinary(Uint8List bytes) {
if (bytes.length < headerSize + 2) {
throw FormatException('Packet too short to contain header and checksum.');
}
final byteData = ByteData.sublistView(bytes);
// Validate Magic
final magic = byteData.getUint16(0, Endian.big);
if (magic != magicNumber) {
throw FormatException('Invalid magic header: 0x${magic.toRadixString(16)}');
}
final version = byteData.getUint8(2);
final messageType = byteData.getUint8(3);
final payloadLength = byteData.getUint32(4, Endian.big);
final timestamp = byteData.getInt64(8, Endian.big);
final expectedTotalSize = headerSize + payloadLength + 2;
if (bytes.length < expectedTotalSize) {
throw FormatException('Incomplete packet payload.');
}
// Verify Checksum
final expectedCrc = byteData.getUint16(expectedTotalSize - 2, Endian.big);
final actualCrc = _calculateChecksum(bytes.sublist(0, expectedTotalSize - 2));
if (expectedCrc != actualCrc) {
throw FormatException('CRC Checksum mismatch! Corrupted frame.');
}
// Extract Payload Text
final payloadSlice = bytes.sublist(headerSize, headerSize + payloadLength);
final payload = utf8.decode(payloadSlice);
return TelemetryPacket(
version: version,
messageType: messageType,
timestamp: timestamp,
payloadText: payload,
);
}
static int _calculateChecksum(Uint8List data) {
var sum = 0;
for (final byte in data) {
sum = (sum + byte) & 0xFFFF;
}
return sum;
}
}
Usage:
void main() {
final packet = TelemetryPacket(
messageType: 0x10,
timestamp: DateTime.now().millisecondsSinceEpoch,
payloadText: '{"temp": 24.5, "battery": 92}',
);
// Encode to wire format:
final binaryData = packet.toBinary();
print('Wire packet length: ${binaryData.length} bytes');
// Decode on receiver side:
final decoded = TelemetryPacket.fromBinary(binaryData);
print('Decoded payload: ${decoded.payloadText}');
}
6. Bitwise Operations & Bitmasking in Dartโ
Binary protocols often pack multiple boolean flags or small integer fields into a single byte:
Bit Layout of a 1-Byte Status Register:
โโโโโโโโโฌโโโโโโโโฌโโโโโโโโฌโโโโโโโโฌโโโโโโโโฌโโโโโโโโฌโโโโโโโโฌโโโโโโโโ
โ Bit 7 โ Bit 6 โ Bit 5 โ Bit 4 โ Bit 3 โ Bit 2 โ Bit 1 โ Bit 0 โ
โ Activeโ Warn โ Error โ Muted โ Battery Level (0-15) โ
โโโโโโโโโดโโโโโโโโดโโโโโโโโดโโโโโโโโดโโโโโโโโดโโโโโโโโดโโโโโโโโดโโโโโโโโ
class DeviceStatus {
static const int maskActive = 1 << 7; // 0b10000000 (128)
static const int maskWarn = 1 << 6; // 0b01000000 (64)
static const int maskError = 1 << 5; // 0b00100000 (32)
static const int maskMuted = 1 << 4; // 0b00010000 (16)
static const int maskBattery = 0x0F; // 0b00001111 (15)
final int rawByte;
const DeviceStatus(this.rawByte);
bool get isActive => (rawByte & maskActive) != 0;
bool get hasWarning => (rawByte & maskWarn) != 0;
bool get hasError => (rawByte & maskError) != 0;
bool get isMuted => (rawByte & maskMuted) != 0;
int get batteryLevel => rawByte & maskBattery;
/// Pack flags into a single byte
static int pack({
required bool active,
required bool warn,
required bool error,
required bool muted,
required int battery,
}) {
assert(battery >= 0 && battery <= 15, 'Battery level must fit in 4 bits (0-15).');
var result = 0;
if (active) result |= maskActive;
if (warn) result |= maskWarn;
if (error) result |= maskError;
if (muted) result |= maskMuted;
result |= (battery & maskBattery);
return result;
}
}
void main() {
final packedByte = DeviceStatus.pack(
active: true,
warn: false,
error: true,
muted: true,
battery: 12,
);
final status = DeviceStatus(packedByte);
print('Is Active: ${status.isActive}'); // true
print('Has Error: ${status.hasError}'); // true
print('Battery Level: ${status.batteryLevel}/15'); // 12
}
7. Fast Buffer Accumulation with BytesBuilderโ
When accumulating binary chunks from a stream (e.g. HTTP chunked transfer, file streaming, or TCP sockets), avoid Uint8List + Uint8List concatenation.
Use BytesBuilder to append bytes efficiently:
import 'dart:typed_data';
void main() {
// Set copy: false for maximum speed if you don't reuse the added lists
final builder = BytesBuilder(copy: false);
builder.add(Uint8List.fromList([1, 2, 3]));
builder.add(Uint8List.fromList([4, 5, 6]));
builder.addByte(7);
// Take ownership of the combined buffer in O(1) time:
final fullBuffer = builder.takeBytes();
print(fullBuffer); // [1, 2, 3, 4, 5, 6, 7]
}
8. Encoding Utilities: Base64 and Hexadecimalโ
Working with binary data often requires converting between binary, Base64, and Hexadecimal representations:
import 'dart:convert';
import 'dart:typed_data';
class ByteConverter {
/// Converts Uint8List to clean Hex string: "0xDEADBEEF" or "deadbeef"
static String toHex(Uint8List bytes, {bool prefix = false}) {
final buffer = StringBuffer(prefix ? '0x' : '');
for (final b in bytes) {
buffer.write(b.toRadixString(16).padLeft(2, '0'));
}
return buffer.toString();
}
/// Parses Hex string to Uint8List
static Uint8List fromHex(String hex) {
var clean = hex.startsWith('0x') ? hex.substring(2) : hex;
if (clean.length.isOdd) {
clean = '0$clean';
}
final result = Uint8List(clean.length ~/ 2);
for (var i = 0; i < clean.length; i += 2) {
result[i ~/ 2] = int.parse(clean.substring(i, i + 2), radix: 16);
}
return result;
}
/// Converts Uint8List to Base64 URL-safe string
static String toBase64(Uint8List bytes) => base64Url.encode(bytes);
/// Decodes Base64 string to Uint8List
static Uint8List fromBase64(String str) => base64Url.decode(str);
}
void main() {
final bytes = Uint8List.fromList([222, 173, 190, 239]); // 0xDEADBEEF
final hex = ByteConverter.toHex(bytes, prefix: true);
print(hex); // "0xdeadbeef"
final roundtrip = ByteConverter.fromHex('deadbeef');
print(roundtrip); // [222, 173, 190, 239]
}
9. Comprehensive Unit Testing Suiteโ
Here is an executable package:test suite verifying endianness math, binary packet framing, CRC validation, and bitmask packing:
import 'dart:typed_data';
import 'package:test/test.dart';
void main() {
group('Endianness Tests', () {
test('Verifies Big-Endian vs Little-Endian byte arrangement', () {
final bd = ByteData(4);
bd.setUint32(0, 0xAABBCCDD, Endian.big);
expect(bd.buffer.asUint8List(), equals([0xAA, 0xBB, 0xCC, 0xDD]));
bd.setUint32(0, 0xAABBCCDD, Endian.little);
expect(bd.buffer.asUint8List(), equals([0xDD, 0xCC, 0xBB, 0xAA]));
});
});
group('Bitmask Packaging Tests', () {
test('Packs and unpacks multi-field status byte accurately', () {
final packed = DeviceStatus.pack(
active: true,
warn: false,
error: true,
muted: false,
battery: 9,
);
final status = DeviceStatus(packed);
expect(status.isActive, isTrue);
expect(status.hasWarning, isFalse);
expect(status.hasError, isTrue);
expect(status.isMuted, isFalse);
expect(status.batteryLevel, equals(9));
});
});
group('TelemetryPacket Framing Tests', () {
test('Serializes and deserializes packet cleanly with valid CRC', () {
final original = TelemetryPacket(
messageType: 0x05,
timestamp: 1714000000000,
payloadText: 'Sensor OK',
);
final bytes = original.toBinary();
final decoded = TelemetryPacket.fromBinary(bytes);
expect(decoded.version, equals(1));
expect(decoded.messageType, equals(0x05));
expect(decoded.timestamp, equals(1714000000000));
expect(decoded.payloadText, equals('Sensor OK'));
});
test('Throws FormatException on corrupted packet payload', () {
final original = TelemetryPacket(
messageType: 0x05,
timestamp: 1714000000000,
payloadText: 'Valid',
);
final bytes = original.toBinary();
// Corrupt a byte in the payload
bytes[17] ^= 0xFF;
expect(() => TelemetryPacket.fromBinary(bytes), throwsFormatException);
});
});
}
10. Summary & Production Best Practicesโ
| Technique | When to Use | Key Benefit |
|---|---|---|
Uint8List | Storing and transferring raw bytes | Zero pointer boxing, direct contiguous memory |
ByteData | Structured binary protocol parsing | Heterogeneous offsets with explicit endianness |
BytesBuilder | Stream accumulation | Fast buffer growth without quadratic copying |
Endian.big | Network / BLE communications | Standardized network byte order |
| Bitwise Masking | Compact flag storage | Pack multiple booleans and numbers into single bytes |
asUint8List(offset, len) | Sub-slicing packet headers/payloads | Zero-copy subview without memory allocation |
Production Rules of Thumb:โ
- Never use
List<int>for binary streams: Always default toUint8List. - Explicitly declare endianness: Always provide
Endian.bigorEndian.littleinByteDatamethod calls. - Use subviews instead of slicing: Prefer
Uint8List.sublistView(buffer, offset, end)oversublist()when you only need read-only access, avoiding heap allocations. - Validate packet lengths before reading: Prevent
RangeErrorcrashes by checking buffer lengths before invokingByteDatagetters.