Flutter + WebSocket: Building Real-Time IoT Dashboards That Don't Stutter
When we built our first IoT dashboard in Flutter, we polled the API every second. It worked fine at demo scale. At production with 200 sensors, it hammered the server and the UI janked constantly.
WebSocket is the right answer. But getting it right in Flutter requires careful architecture.
The Architecture
IoT Devices → MQTT Broker → Node.js Backend
↓ WebSocket
Flutter App
↓ ↓
Riverpod fl_chart
State Real-time Charts
WebSocket Service
The WebSocket service is the heart of the real-time layer. It needs:
class IoTWebSocketService {
WebSocketChannel? _channel;
final _controller = StreamController.broadcast();
Timer? _reconnectTimer;
int _reconnectAttempts = 0; Stream get dataStream => _controller.stream;
void connect(String url) {
try {
_channel = WebSocketChannel.connect(Uri.parse(url));
_reconnectAttempts = 0;
_channel!.stream.listen(
_handleMessage,
onError: _handleError,
onDone: _scheduleReconnect,
);
} catch (e) {
_scheduleReconnect();
}
}
void _handleMessage(dynamic raw) {
final json = jsonDecode(raw as String);
final data = DeviceData.fromJson(json);
_controller.add(data);
}
void _scheduleReconnect() {
final delay = Duration(
seconds: min(30, pow(2, _reconnectAttempts).toInt()),
);
_reconnectAttempts++;
_reconnectTimer = Timer(delay, () => connect(_url));
}
}
State Management with Riverpod
Riverpod's StreamProvider is a perfect fit for WebSocket data:
final iotDataProvider = StreamProvider.family((ref, deviceId) {
final service = ref.watch(wsServiceProvider);
return service.dataStream.where((d) => d.deviceId == deviceId);
});// In your widget:
class SensorCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(iotDataProvider('device-001'));
return data.when(
data: (d) => SensorDisplay(data: d),
loading: () => const SkeletonLoader(),
error: (e, _) => const ErrorState(),
);
}
}
60fps Charts Without Jank
The key to smooth real-time charts is limiting the data window and using RepaintBoundary:
class LiveChart extends StatefulWidget {
final Stream valueStream;
final int windowSize; // show last N seconds // ...
}
class _LiveChartState extends State {
final _buffer = ListQueue();
int _tick = 0;
@override
void initState() {
super.initState();
widget.valueStream.listen((value) {
setState(() {
_buffer.add(FlSpot(_tick.toDouble(), value));
if (_buffer.length > widget.windowSize) _buffer.removeFirst();
_tick++;
});
});
}
@override
Widget build(BuildContext context) {
return RepaintBoundary( // Isolate chart repaints from parent
child: LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
spots: _buffer.toList(),
isCurved: true,
preventCurveOverShooting: true,
color: const Color(0xFFFF6B35),
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: const Color(0x22FF6B35),
),
),
],
// Disable animations for real-time data
lineTouchData: const LineTouchData(enabled: false),
),
duration: Duration.zero,
),
);
}
}
Offline-First with Hive
IoT apps must work without network. Store the last known state:
@HiveType(typeId: 0)
class DeviceSnapshot extends HiveObject {
@HiveField(0) String deviceId;
@HiveField(1) double lastTemperature;
@HiveField(2) DateTime lastSeen;
@HiveField(3) bool isOnline;
}// Cache on every update
wsService.dataStream.listen((data) {
final box = Hive.box('devices');
box.put(data.deviceId, DeviceSnapshot.fromData(data));
});
Performance Tips
const constructors everywhere in list itemsaddRepaintBoundaries: true for device listsThe difference between a smooth IoT dashboard and a janky one is usually in the state management layer. Get that right and the rest follows.
Want us to build your IoT dashboard? [Let's talk](/contact).