Skip to content
ExponaBLOG
FLUTTER · DAY 064 / 100

Real-time features in Flutter: best practices explained

WebSockets and streams in mobile apps.

Expona Admin8 min read

Real-time features in Flutter are no longer optional—they're expected in modern mobile apps. Whether you're building a live chat, collaborative editor, or trading platform, your users demand instant updates without page refreshes. In this post, I'll walk you through the patterns that have worked across production systems I've built, from handling WebSocket connections to keeping your app responsive when the network flickers.

Why Real-time Matters in Mobile

Five years ago, polling was acceptable. Today it drains batteries, wastes bandwidth, and feels sluggish. WebSockets and persistent streams let you push data to the client the moment it changes. But mobile isn't the web—your app can go to the background, lose WiFi mid-request, or switch between networks. The difference between a polished real-time feature and a frustrating one is how you handle these constraints.

I learned this the hard way on a ride-hailing project where the driver's location stream would disconnect silently. Users thought the app was broken; it was just our socket reconnection logic sleeping. Real-time in Flutter demands intentionality.

Connect in a Service — A Socket Service Exposes Streams to the App

Your first instinct might be to throw WebSocket logic into your page or state manager. Don't. Encapsulate it in a dedicated service. This keeps your concerns clean and lets you reuse the same connection across multiple screens.

Here's a minimal socket service pattern I use:

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

class SocketService {
  final String url;
  WebSocketChannel? _channel;
  final StreamController<dynamic> _eventStream = StreamController.broadcast();
  
  SocketService({required this.url});
  
  Stream<dynamic> get events => _eventStream.stream;
  bool get isConnected => _channel != null;
  
  Future<void> connect() async {
    try {
      _channel = WebSocketChannel.connect(Uri.parse(url));
      _channel!.stream.listen(
        (message) {
          _eventStream.add(message);
        },
        onError: (error) {
          print('Socket error: $error');
          _eventStream.addError(error);
        },
        onDone: () {
          print('Socket closed');
          _channel = null;
        },
      );
    } catch (e) {
      _eventStream.addError(e);
      rethrow;
    }
  }
  
  void send(String message) {
    if (_channel != null) {
      _channel!.sink.add(message);
    } else {
      print('Socket not connected');
    }
  }
  
  Future<void> disconnect() async {
    await _channel?.sink.close();
    _channel = null;
  }
  
  void dispose() {
    _eventStream.close();
    _channel?.sink.close();
  }
}

The key insight: expose a single Stream<dynamic> that your app listens to. Don't expose the raw channel. This gives you control over what events flow out and lets you transform or filter them later.

In a real app, I'd also add typed events. Use JSON decoding in the service so your UI code never touches raw strings:

dart
final StreamController<SocketEvent> _eventStream = StreamController.broadcast();

Stream<SocketEvent> get events => _eventStream.stream;

void _onMessage(dynamic message) {
  try {
    final json = jsonDecode(message);
    final event = SocketEvent.fromJson(json);
    _eventStream.add(event);
  } catch (e) {
    _eventStream.addError(e);
  }
}

Reconnect Logic — Exponential Backoff and Network Change Detection

Reconnection is where most implementations fail silently. Your socket closes, and nothing retries. Users don't know why data stopped flowing.

I use two strategies: exponential backoff for temporary failures, and network change detection to handle transitions between WiFi and cellular.

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

class ResilientSocketService {
  final String url;
  WebSocketChannel? _channel;
  final StreamController<SocketEvent> _eventStream = StreamController.broadcast();
  
  int _retryCount = 0;
  static const int _maxRetries = 5;
  static const Duration _baseDelay = Duration(seconds: 1);
  
  Timer? _reconnectTimer;
  StreamSubscription? _connectivitySubscription;
  
  Future<void> connect() async {
    _retryCount = 0;
    _attemptConnect();
    _listenToNetworkChanges();
  }
  
  void _attemptConnect() {
    try {
      _channel = WebSocketChannel.connect(Uri.parse(url));
      _retryCount = 0; // Reset on success
      
      _channel!.stream.listen(
        (message) {
          try {
            final json = jsonDecode(message);
            final event = SocketEvent.fromJson(json);
            _eventStream.add(event);
          } catch (e) {
            _eventStream.addError(e);
          }
        },
        onError: (error) {
          print('Socket error: $error');
          _scheduleReconnect();
        },
        onDone: () {
          print('Socket closed, will retry');
          _scheduleReconnect();
        },
      );
    } catch (e) {
      print('Connect failed: $e');
      _scheduleReconnect();
    }
  }
  
  void _scheduleReconnect() {
    if (_retryCount >= _maxRetries) {
      _eventStream.addError('Max reconnection attempts reached');
      return;
    }
    
    _retryCount++;
    final delay = _baseDelay * (2 ^ (_retryCount - 1));
    print('Reconnecting in ${delay.inSeconds}s (attempt $_retryCount)');
    
    _reconnectTimer?.cancel();
    _reconnectTimer = Timer(delay, _attemptConnect);
  }
  
  void _listenToNetworkChanges() {
    _connectivitySubscription = Connectivity().onConnectivityChanged.listen((result) {
      if (result != ConnectivityResult.none) {
        print('Network available, reconnecting if needed');
        if (_channel == null) {
          _retryCount = 0;
          _attemptConnect();
        }
      }
    });
  }
  
  Future<void> dispose() async {
    _reconnectTimer?.cancel();
    await _connectivitySubscription?.cancel();
    await _channel?.sink.close();
    await _eventStream.close();
  }
}

The exponential backoff prevents hammering your server when it's struggling. After 5 failed attempts, we give up and let the app surface the error to the user. Network change detection catches the case where WiFi drops and cellular kicks in—we reconnect immediately instead of waiting for the next backoff tick.

Lifecycle Aware — Pause or Close Sockets in the Background

Leaving a WebSocket open while your app is backgrounded wastes battery and data. On iOS especially, long-lived connections can trigger battery drain warnings. The best practice: close the socket when backgrounding, reconnect when resuming.

Use the app_lifecycle package (part of Flutter lifecycle events):

dart
class SocketManager {
  final ResilientSocketService socketService;
  late AppLifecycleListener _lifecycleListener;
  
  SocketManager({required this.socketService});
  
  void init(BuildContext context) {
    _lifecycleListener = AppLifecycleListener(
      onResume: _onResume,
      onPause: _onPause,
      onDetach: _onDetach,
    );
  }
  
  Future<void> _onResume() async {
    print('App resumed, reconnecting socket');
    await socketService.connect();
  }
  
  Future<void> _onPause() async {
    print('App paused, closing socket');
    await socketService.disconnect();
  }
  
  Future<void> _onDetach() async {
    print('App detached, cleaning up');
    await socketService.dispose();
  }
  
  void dispose() {
    _lifecycleListener.dispose();
  }
}

Alternatively, if you need more granular control, listen to WidgetsBindingObserver (older Flutter versions) or use providers with lifecycle hooks. The key principle: match socket lifetime to app visibility.

StreamBuilder or State Management — Push Socket Events Into Your State Layer

Once your socket service is feeding events, you need to integrate with your app's state. I've used both StreamBuilder and Riverpod, depending on complexity.

For simple screens, StreamBuilder is clean:

dart
class ChatScreen extends StatefulWidget {
  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  late SocketService socketService;
  
  @override
  void initState() {
    super.initState();
    socketService = context.read<SocketService>();
  }
  
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<SocketEvent>(
      stream: socketService.events,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('Connection lost: ${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return Center(child: CircularProgressIndicator());
        }
        
        final event = snapshot.data!;
        if (event is MessageEvent) {
          // Handle new message
          return ChatMessageWidget(message: event.message);
        }
        
        return SizedBox.shrink();
      },
    );
  }
}

For apps with shared state or multiple screens listening to the same socket, I prefer a state manager. Here's a Riverpod example:

dart
final socketServiceProvider = Provider<ResilientSocketService>((ref) {
  return ResilientSocketService(url: 'wss://api.example.com/socket');
});

final socketEventsProvider = StreamProvider<SocketEvent>((ref) {
  return ref.watch(socketServiceProvider).events;
});

// In your widget:
class ChatScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final events = ref.watch(socketEventsProvider);
    
    return events.when(
      data: (event) => ChatMessageWidget(event: event),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('Error: $err'),
    );
  }
}

Riverpod (or similar) shines when multiple screens need the socket events. The provider caches the stream subscription and keeps it alive across navigation.

Common Mistakes

  • Not disposing streams: Forgetting dispose() on your StreamController leaks memory. Always call it in onDispose or your lifecycle handler.
  • Blocking the UI thread with expensive JSON decoding: Decode on the socket thread or in an isolate if your payloads are large. Don't freeze your UI.
  • Ignoring network transitions: Relying only on socket errors means you'll miss the case where the network genuinely changed. Listen to connectivity changes in parallel.
  • Reconnecting too aggressively: Without exponential backoff, a failing server gets hammered with reconnect attempts every 100ms. Your backoff delay should grow with each retry.
  • Leaving sockets open in the background: This drains battery and can trigger iOS background limits. Always close on pause.
  • Mixing raw channels and state: If you expose your WebSocketChannel directly, multiple parts of your app might listen, or you might close it from the wrong place. Encapsulation prevents this.

Key Takeaways

  • Encapsulate WebSocket logic in a dedicated service that exposes typed streams, not raw channels.
  • Implement exponential backoff for reconnection and listen to network changes to catch transitions.
  • Close sockets when your app backgrounding; reconnect on resume to save battery.
  • Integrate socket events with your state manager (Riverpod, GetX, BLoC) so multiple screens can listen without duplicating connections.
  • Always decode JSON in the socket service, not in your UI, to keep the main thread free.
  • Test reconnection by toggling airplane mode or killing your backend process—don't trust happy-path testing.

Real-time features feel magical when they work. But they're built on solid foundations: clean architecture, resilient connection handling, and lifecycle awareness. Start with the patterns here, test edge cases ruthlessly, and your users will notice the difference.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles