Back to Blog
Mobile Development

Flutter Offline-First IoT Apps: Hive + Sync Architecture That Works in the Field

Factory floors, remote farms, and oil fields have unreliable connectivity. IoT apps that require internet to show data fail your clients. This guide builds a Flutter offline-first architecture using Hive local storage with intelligent cloud sync.

September 15, 2024
12 min read
FlutterOffline-FirstHiveIoT

Flutter Offline-First IoT Apps: Hive + Sync Architecture That Works in the Field

Here's a scenario that happens constantly: a maintenance technician is inspecting IoT sensors in a basement or a remote site with poor signal. The app spins, shows "No connection", and the technician can't see the last known device state or log their inspection notes.

IoT apps that require internet to display data have failed in their fundamental purpose. The devices exist in the physical world; your app needs to too.

This guide builds a production offline-first Flutter architecture using Hive for local storage and a smart sync queue for cloud reconciliation.

Why Hive for IoT Apps

Hive is a pure Dart NoSQL key-value store — no native code, no SQLite overhead. For IoT apps:

  • Fast: reads are in-memory after initial load
  • Typed: Hive adapters give you type-safe models
  • Portable: works identically on iOS, Android, and Flutter web
  • Simple: no schema migrations, no SQL
  • For relational queries (filter all devices by location), combine Hive with a simple in-memory index or use Isar (Hive's successor with full query support).

    Step 1: Hive Setup

    dependencies:
      hive_flutter: ^1.1.0
      connectivity_plus: ^6.0.0
      riverpod: ^2.5.0

    dev_dependencies: hive_generator: ^2.0.0 build_runner: ^2.4.0

    Define your data models:

    import 'package:hive/hive.dart';

    part 'device_snapshot.g.dart';

    @HiveType(typeId: 0) class DeviceSnapshot extends HiveObject { @HiveField(0) String deviceId; @HiveField(1) double? temperature; @HiveField(2) double? humidity; @HiveField(3) bool isOnline; @HiveField(4) DateTime lastSeen; @HiveField(5) String firmwareVersion; @HiveField(6) String location;

    DeviceSnapshot({ required this.deviceId, this.temperature, this.humidity, required this.isOnline, required this.lastSeen, required this.firmwareVersion, required this.location, }); }

    @HiveType(typeId: 1) class SyncQueueItem extends HiveObject { @HiveField(0) String id; @HiveField(1) String type; // 'command', 'note', 'ack' @HiveField(2) String payload; // JSON @HiveField(3) DateTime createdAt; @HiveField(4) int retryCount; @HiveField(5) bool synced;

    SyncQueueItem({ required this.id, required this.type, required this.payload, required this.createdAt, this.retryCount = 0, this.synced = false }); }

    flutter pub run build_runner build --delete-conflicting-outputs
    

    Initialize Hive in main.dart:

    Future<void> main() async {
      WidgetsFlutterBinding.ensureInitialized();
      await Hive.initFlutter();
      Hive.registerAdapter(DeviceSnapshotAdapter());
      Hive.registerAdapter(SyncQueueItemAdapter());
      await Hive.openBox<DeviceSnapshot>('devices');
      await Hive.openBox<SyncQueueItem>('syncQueue');
      runApp(const ProviderScope(child: MyApp()));
    }
    

    Step 2: Repository Pattern

    Wrap all data access behind a repository — the UI never talks to Hive or the API directly:

    class DeviceRepository {
      final Box<DeviceSnapshot> _local = Hive.box('devices');
      final ApiService _api;

    DeviceRepository(this._api);

    // Always read local first List<DeviceSnapshot> getAllDevices() => _local.values.toList();

    DeviceSnapshot? getDevice(String deviceId) => _local.get(deviceId);

    // Update local immediately, queue cloud sync Future<void> updateFromTelemetry(String deviceId, Map<String, dynamic> data) async { final existing = _local.get(deviceId); final snapshot = DeviceSnapshot( deviceId: deviceId, temperature: data['temperature']?.toDouble(), humidity: data['humidity']?.toDouble(), isOnline: true, lastSeen: DateTime.now(), firmwareVersion: data['firmware'] ?? existing?.firmwareVersion ?? 'unknown', location: existing?.location ?? 'Unknown', ); await _local.put(deviceId, snapshot); }

    // For initial load: fetch from API, cache locally Future<void> refreshFromCloud() async { try { final devices = await _api.getDevices(); for (final device in devices) { await _local.put(device.deviceId, device); } } catch (_) { // Silently fail — we have local data } } }

    Step 3: Connectivity Monitoring

    final connectivityProvider = StreamProvider<bool>((ref) {
      return Connectivity().onConnectivityChanged.map(
        (result) => result != ConnectivityResult.none,
      );
    });

    // Trigger sync when connectivity restored final syncOnReconnectProvider = Provider((ref) { ref.listen<AsyncValue<bool>>(connectivityProvider, (prev, next) { final wasOffline = prev?.value == false; final isNowOnline = next.value == true; if (wasOffline && isNowOnline) { ref.read(syncServiceProvider).processSyncQueue(); } }); });

    Step 4: Sync Queue for Offline Actions

    When offline, user actions (sending a device command, logging an inspection note) go into a sync queue:

    class SyncService {
      final Box<SyncQueueItem> _queue = Hive.box('syncQueue');
      final ApiService _api;

    Future<void> enqueue(String type, Map<String, dynamic> payload) async { final item = SyncQueueItem( id: const Uuid().v4(), type: type, payload: jsonEncode(payload), createdAt: DateTime.now(), ); await _queue.put(item.id, item); }

    Future<void> processSyncQueue() async { final pending = _queue.values.where((i) => !i.synced).toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); // FIFO

    for (final item in pending) { try { await _processItem(item); item.synced = true; await item.save(); } catch (e) { item.retryCount++; await item.save(); if (item.retryCount >= 5) { // Dead letter — notify user await _notifyFailedSync(item); } } } }

    Future<void> _processItem(SyncQueueItem item) async { final payload = jsonDecode(item.payload) as Map<String, dynamic>; switch (item.type) { case 'command': await _api.sendDeviceCommand(payload['deviceId'], payload['command']); case 'inspection_note': await _api.saveNote(payload); case 'alert_ack': await _api.acknowledgeAlert(payload['alertId']); } } }

    Step 5: Optimistic UI

    Show the user the result of their action immediately — don't wait for cloud confirmation:

    Future<void> sendDeviceCommand(String deviceId, String command) async {
      // 1. Update local state immediately (optimistic)
      final device = _local.get(deviceId)!;
      device.lastCommand = command;
      device.lastCommandAt = DateTime.now();
      await device.save();

    // 2. Attempt cloud sync final isOnline = await Connectivity().checkConnectivity() != ConnectivityResult.none; if (isOnline) { try { await _api.sendDeviceCommand(deviceId, command); } catch (_) { // Fall through to queue } }

    // 3. Queue for sync if offline or API failed await syncService.enqueue('command', {'deviceId': deviceId, 'command': command}); }

    Offline Indicators in the UI

    Always show the user when they're working with cached data:

    class DeviceScreen extends ConsumerWidget {
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final isOnline = ref.watch(connectivityProvider).value ?? true;
        final device   = ref.watch(deviceProvider(deviceId));

    return Scaffold( appBar: AppBar( title: const Text('Device Monitor'), actions: [ if (!isOnline) const Chip( label: Text('Offline — cached data'), backgroundColor: Colors.orange, ), ], ), body: Column(children: [ if (device.lastSeen.isBefore(DateTime.now().subtract(const Duration(hours: 1)))) const Banner(message: 'Data may be stale', location: BannerLocation.topEnd), DeviceDataPanel(device: device), ]), ); } }

    Stale Data Strategy

    | Data Age | UI Treatment | |----------|--------------| | < 5 min | Show normally | | 5–60 min | Subtle "last seen X ago" label | | 1–24 hr | Yellow warning badge | | > 24 hr | Red staleness warning, prompt refresh |

    IoT apps deployed in factories, farms, and remote installations need to work where connectivity doesn't. [Contact Code Caracal](/contact) to build your offline-first IoT mobile app.

    ⚡
    Written by CodeCaracal Engineering

    We write from production experience — every technique in our articles has been deployed to real clients. No academic theory.

    Let's Build Together

    Got an IoT challenge?
    We've shipped it.

    Whether you need a fleet to track, a factory to monitor, or a farm to automate — our team has done it before and we'd love to build it with you. Typical response time: under 24 hours.

    No upfront commitment99.9% uptime SLANDA on requestFixed-price options