Networking & REST API Integration

Connectivity Detection & Offline-First Architecture

16 min Lesson 12 of 13

Connectivity Detection & Offline-First Architecture

Modern mobile users expect apps to work regardless of network conditions. An offline-first architecture means your app treats the local device as the primary data source and treats the network as an optional, eventually-consistent sync layer. When the device is online, changes are pushed to the server; when offline, they are queued locally and replayed automatically once connectivity is restored.

Flutter's connectivity_plus package gives you a real-time stream of ConnectivityResult values so you can react instantly to network state transitions — no polling required.

Note: ConnectivityResult tells you what kind of network interface is available (WiFi, mobile, ethernet, none). It does not guarantee that the server you need is reachable. Always combine connectivity detection with actual request error handling for robust offline logic.

Adding the Dependency

Add connectivity_plus to pubspec.yaml, then add a local queue with sqflite (SQLite for Flutter) or a simple JSON file via path_provider:

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  connectivity_plus: ^6.0.3
  sqflite: ^2.3.3
  path_provider: ^2.1.3

Listening to Connectivity Changes

The Connectivity class exposes two APIs:

  • checkConnectivity() — a one-shot Future that returns the current status.
  • onConnectivityChanged — a Stream<List<ConnectivityResult>> that emits every time the network state changes.

The best pattern is to check once on startup, then subscribe to the stream for ongoing changes. Cancel the subscription in dispose() to prevent memory leaks.

ConnectivityService — singleton

import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';

class ConnectivityService {
  ConnectivityService._();
  static final ConnectivityService instance = ConnectivityService._();

  final Connectivity _connectivity = Connectivity();
  final StreamController<bool> _controller =
      StreamController<bool>.broadcast();

  StreamSubscription<List<ConnectivityResult>>? _subscription;

  /// Expose a simple bool stream: true = online, false = offline.
  Stream<bool> get onStatusChanged => _controller.stream;

  bool _isOnline = false;
  bool get isOnline => _isOnline;

  Future<void> init() async {
    // Check once at startup
    final results = await _connectivity.checkConnectivity();
    _updateStatus(results);

    // Subscribe to future changes
    _subscription = _connectivity.onConnectivityChanged.listen(
      _updateStatus,
    );
  }

  void _updateStatus(List<ConnectivityResult> results) {
    final online = results.any(
      (r) => r != ConnectivityResult.none,
    );
    if (online != _isOnline) {
      _isOnline = online;
      _controller.add(_isOnline);
    }
  }

  void dispose() {
    _subscription?.cancel();
    _controller.close();
  }
}

The Offline Queue Pattern

Write operations (POST, PUT, DELETE) that fail while offline must not be silently dropped. Instead, persist them in a local pending-operations queue. When connectivity is restored, drain the queue in order.

Each queued operation should store:

  • The HTTP method and relative endpoint path.
  • A JSON-encoded request body.
  • A timestamp and an optional retry count.
  • A stable client-generated ID so duplicate replays are idempotent.

PendingOperation model + OfflineQueue (in-memory demo)

import 'dart:convert';
import 'package:uuid/uuid.dart';

class PendingOperation {
  final String id;
  final String method;   // 'POST', 'PUT', 'DELETE'
  final String path;
  final Map<String, dynamic> body;
  final DateTime createdAt;
  int retries;

  PendingOperation({
    String? id,
    required this.method,
    required this.path,
    required this.body,
    DateTime? createdAt,
    this.retries = 0,
  })  : id = id ?? const Uuid().v4(),
        createdAt = createdAt ?? DateTime.now();

  Map<String, dynamic> toJson() => {
        'id': id,
        'method': method,
        'path': path,
        'body': body,
        'createdAt': createdAt.toIso8601String(),
        'retries': retries,
      };

  factory PendingOperation.fromJson(Map<String, dynamic> json) =>
      PendingOperation(
        id: json['id'] as String,
        method: json['method'] as String,
        path: json['path'] as String,
        body: Map<String, dynamic>.from(json['body'] as Map),
        createdAt: DateTime.parse(json['createdAt'] as String),
        retries: json['retries'] as int? ?? 0,
      );
}

/// Minimal in-memory queue; swap for SQLite in production.
class OfflineQueue {
  final List<PendingOperation> _ops = [];

  void enqueue(PendingOperation op) => _ops.add(op);

  List<PendingOperation> drain() {
    final copy = List<PendingOperation>.from(_ops);
    _ops.clear();
    return copy;
  }

  bool get isEmpty => _ops.isEmpty;
  int get length => _ops.length;
}

Auto-Sync on Reconnect

Wire the ConnectivityService stream to a sync handler. When the device comes back online, call your API for each queued operation. Use exponential back-off and a max-retry limit to avoid hammering a flaky connection.

SyncManager — drains the queue when online

import 'dart:async';
import 'package:http/http.dart' as http;
import 'dart:convert';

class SyncManager {
  final OfflineQueue _queue;
  final ConnectivityService _connectivity;
  StreamSubscription<bool>? _sub;

  static const String _baseUrl = 'https://api.example.com';
  static const int _maxRetries = 3;

  SyncManager(this._queue, this._connectivity);

  void start() {
    _sub = _connectivity.onStatusChanged.listen((isOnline) {
      if (isOnline) _syncPending();
    });
  }

  Future<void> _syncPending() async {
    if (_queue.isEmpty) return;

    final ops = _queue.drain();
    for (final op in ops) {
      final success = await _replay(op);
      if (!success && op.retries < _maxRetries) {
        op.retries++;
        _queue.enqueue(op); // re-queue for next attempt
      }
    }
  }

  Future<bool> _replay(PendingOperation op) async {
    try {
      final uri = Uri.parse('$_baseUrl${op.path}');
      final headers = {'Content-Type': 'application/json'};
      final encoded = jsonEncode(op.body);
      http.Response response;

      switch (op.method) {
        case 'POST':
          response = await http.post(uri, headers: headers, body: encoded);
        case 'PUT':
          response = await http.put(uri, headers: headers, body: encoded);
        case 'DELETE':
          response = await http.delete(uri, headers: headers, body: encoded);
        default:
          return false;
      }
      return response.statusCode >= 200 && response.statusCode < 300;
    } catch (_) {
      return false;
    }
  }

  void stop() => _sub?.cancel();
}

Showing Offline Status in the UI

Wrap your scaffold body with a StreamBuilder (or a ValueListenableBuilder if you expose a ValueNotifier) to show a persistent banner whenever the device is offline. This gives users immediate visual feedback without interrupting their workflow.

Tip: Show a non-blocking banner (e.g., a MaterialBanner or a coloured strip below the app bar) rather than a blocking dialog. Users should still be able to read cached content and compose new entries while offline.

Platform Permissions

On Android, add the following to AndroidManifest.xml inside the <manifest> tag:

AndroidManifest.xml permission

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

No special entitlement is required on iOS — the connectivity_plus plugin handles the necessary Network framework calls automatically.

Warning: Never store sensitive credentials (API tokens, passwords) in the offline queue's persisted JSON. If the queue is stored on disk, encrypt it or store only the minimum data needed to reconstruct the request at sync time.

Summary

An offline-first Flutter app uses three building blocks: connectivity detection (via connectivity_plus streams), a local pending-operations queue (SQLite or file-backed), and an auto-sync manager that drains the queue when the device reconnects. Together they ensure that write operations are never silently lost and that users enjoy a seamless experience regardless of network quality.