tests.ws

Flutter WebSocket Guide

flutter dart websocket mobile real-time

Flutter WebSocket connections enable real-time, bidirectional communication between mobile, web, and desktop applications and servers. Whether you’re building a chat application, live dashboard, or collaborative tool, understanding how to implement socket in flutter applications is essential for modern app development. This guide covers everything from basic connections to production-ready implementations with proper error handling and reconnection strategies.

The web_socket_channel Package

The web_socket_channel package provides the standard way to work with flutter web socket connections. It offers a unified API that works across all Flutter platforms, abstracting platform-specific implementations while maintaining consistent behavior.

Install the package by adding it to your pubspec.yaml:

dependencies:
  web_socket_channel: ^2.4.0

The package provides WebSocketChannel as the main class for managing connections. It handles the underlying platform differences automatically, using dart:io for mobile and desktop platforms and dart:html for web platforms.

import 'package:web_socket_channel/web_socket_channel.dart';

class WebSocketService {
  WebSocketChannel? _channel;

  WebSocketChannel get channel {
    if (_channel == null) {
      throw Exception('WebSocket not connected');
    }
    return _channel!;
  }
}

Connecting to a WebSocket Server

Establishing a flutter websocket connection requires the server URL and proper protocol handling. The URL must use the ws:// scheme for unencrypted connections or wss:// for secure connections over TLS.

import 'package:web_socket_channel/web_socket_channel.dart';

class WebSocketService {
  WebSocketChannel? _channel;
  final String url;

  WebSocketService(this.url);

  void connect() {
    try {
      _channel = WebSocketChannel.connect(
        Uri.parse(url),
      );
      print('Connected to WebSocket');
    } catch (e) {
      print('Connection failed: $e');
      rethrow;
    }
  }

  void disconnect() {
    _channel?.sink.close();
    _channel = null;
  }
}

For production applications, you’ll want to handle connection headers and protocols:

void connectWithHeaders() {
  _channel = WebSocketChannel.connect(
    Uri.parse(url),
    protocols: ['websocket'],
  );
}

The connection establishes asynchronously. Monitor the stream to detect when the connection is ready or encounters errors.

Sending and Receiving Messages

WebSocket channels provide a sink for sending data and a stream for receiving messages. The dart websocket implementation uses these primitives to create a clean, reactive API.

class WebSocketService {
  WebSocketChannel? _channel;

  void sendMessage(String message) {
    _channel?.sink.add(message);
  }

  void sendJson(Map<String, dynamic> data) {
    final encoded = jsonEncode(data);
    _channel?.sink.add(encoded);
  }

  Stream<dynamic> get messages {
    return _channel!.stream;
  }
}

Handle different message types based on your protocol:

import 'dart:convert';

class MessageHandler {
  void handleMessage(dynamic message) {
    if (message is String) {
      try {
        final decoded = jsonDecode(message);
        handleJsonMessage(decoded);
      } catch (e) {
        handleTextMessage(message);
      }
    } else if (message is List<int>) {
      handleBinaryMessage(message);
    }
  }

  void handleJsonMessage(Map<String, dynamic> data) {
    final type = data['type'];
    switch (type) {
      case 'chat':
        print('Chat message: ${data['content']}');
        break;
      case 'notification':
        print('Notification: ${data['message']}');
        break;
      default:
        print('Unknown message type: $type');
    }
  }

  void handleTextMessage(String text) {
    print('Text message: $text');
  }

  void handleBinaryMessage(List<int> bytes) {
    print('Binary message: ${bytes.length} bytes');
  }
}

StreamBuilder Widget Integration

The StreamBuilder widget provides the perfect tool for connecting flutter sockets to your UI. It rebuilds automatically when new messages arrive, keeping your interface synchronized with server state.

import 'package:flutter/material.dart';

class ChatScreen extends StatefulWidget {
  final WebSocketService webSocket;

  const ChatScreen({required this.webSocket, Key? key}) : super(key: key);

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final List<String> _messages = [];
  final TextEditingController _controller = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('WebSocket Chat')),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder(
              stream: widget.webSocket.messages,
              builder: (context, snapshot) {
                if (snapshot.hasError) {
                  return Center(
                    child: Text('Error: ${snapshot.error}'),
                  );
                }

                if (snapshot.hasData) {
                  final message = snapshot.data.toString();
                  if (!_messages.contains(message)) {
                    _messages.add(message);
                  }
                }

                return ListView.builder(
                  itemCount: _messages.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text(_messages[index]),
                    );
                  },
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: 'Enter message',
                    ),
                  ),
                ),
                IconButton(
                  icon: const Icon(Icons.send),
                  onPressed: () {
                    if (_controller.text.isNotEmpty) {
                      widget.webSocket.sendMessage(_controller.text);
                      _controller.clear();
                    }
                  },
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

For better performance with large message lists, use a proper state management solution instead of storing messages in the widget state.

State Management with Provider and Riverpod

Managing websocket flutter connections requires proper state management to handle connection status, messages, and errors across your application. Provider and Riverpod offer clean solutions for this.

Provider Implementation

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

class WebSocketProvider extends ChangeNotifier {
  WebSocketChannel? _channel;
  final String url;
  ConnectionStatus _status = ConnectionStatus.disconnected;
  final List<String> _messages = [];
  StreamSubscription? _subscription;

  WebSocketProvider(this.url);

  ConnectionStatus get status => _status;
  List<String> get messages => List.unmodifiable(_messages);

  void connect() {
    if (_status == ConnectionStatus.connected) return;

    _status = ConnectionStatus.connecting;
    notifyListeners();

    try {
      _channel = WebSocketChannel.connect(Uri.parse(url));

      _subscription = _channel!.stream.listen(
        _handleMessage,
        onError: _handleError,
        onDone: _handleDone,
      );

      _status = ConnectionStatus.connected;
      notifyListeners();
    } catch (e) {
      _status = ConnectionStatus.error;
      notifyListeners();
    }
  }

  void _handleMessage(dynamic message) {
    _messages.add(message.toString());
    notifyListeners();
  }

  void _handleError(error) {
    _status = ConnectionStatus.error;
    notifyListeners();
  }

  void _handleDone() {
    _status = ConnectionStatus.disconnected;
    notifyListeners();
  }

  void sendMessage(String message) {
    _channel?.sink.add(message);
  }

  void disconnect() {
    _subscription?.cancel();
    _channel?.sink.close();
    _status = ConnectionStatus.disconnected;
    notifyListeners();
  }

  @override
  void dispose() {
    disconnect();
    super.dispose();
  }
}

enum ConnectionStatus {
  disconnected,
  connecting,
  connected,
  error,
}

Use the provider in your widget tree:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => WebSocketProvider('wss://echo.websocket.org'),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const ChatPage(),
    );
  }
}

class ChatPage extends StatefulWidget {
  const ChatPage({Key? key}) : super(key: key);

  @override
  State<ChatPage> createState() => _ChatPageState();
}

class _ChatPageState extends State<ChatPage> {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<WebSocketProvider>().connect();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('WebSocket Chat'),
        actions: [
          Consumer<WebSocketProvider>(
            builder: (context, ws, _) {
              return Icon(
                Icons.circle,
                color: ws.status == ConnectionStatus.connected
                    ? Colors.green
                    : Colors.red,
                size: 16,
              );
            },
          ),
          const SizedBox(width: 16),
        ],
      ),
      body: Consumer<WebSocketProvider>(
        builder: (context, ws, _) {
          return ListView.builder(
            itemCount: ws.messages.length,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text(ws.messages[index]),
              );
            },
          );
        },
      ),
    );
  }
}

Riverpod Implementation

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

class WebSocketNotifier extends StateNotifier<WebSocketState> {
  WebSocketChannel? _channel;
  final String url;

  WebSocketNotifier(this.url) : super(const WebSocketState());

  void connect() {
    state = state.copyWith(status: ConnectionStatus.connecting);

    try {
      _channel = WebSocketChannel.connect(Uri.parse(url));

      _channel!.stream.listen(
        (message) {
          state = state.copyWith(
            status: ConnectionStatus.connected,
            messages: [...state.messages, message.toString()],
          );
        },
        onError: (error) {
          state = state.copyWith(
            status: ConnectionStatus.error,
            error: error.toString(),
          );
        },
        onDone: () {
          state = state.copyWith(status: ConnectionStatus.disconnected);
        },
      );

      state = state.copyWith(status: ConnectionStatus.connected);
    } catch (e) {
      state = state.copyWith(
        status: ConnectionStatus.error,
        error: e.toString(),
      );
    }
  }

  void sendMessage(String message) {
    _channel?.sink.add(message);
  }

  void disconnect() {
    _channel?.sink.close();
    state = state.copyWith(status: ConnectionStatus.disconnected);
  }
}

class WebSocketState {
  final ConnectionStatus status;
  final List<String> messages;
  final String? error;

  const WebSocketState({
    this.status = ConnectionStatus.disconnected,
    this.messages = const [],
    this.error,
  });

  WebSocketState copyWith({
    ConnectionStatus? status,
    List<String>? messages,
    String? error,
  }) {
    return WebSocketState(
      status: status ?? this.status,
      messages: messages ?? this.messages,
      error: error ?? this.error,
    );
  }
}

final webSocketProvider = StateNotifierProvider<WebSocketNotifier, WebSocketState>(
  (ref) => WebSocketNotifier('wss://echo.websocket.org'),
);

Reconnection Logic

Production applications need robust reconnection strategies. Network conditions change, servers restart, and connections drop. Implementing automatic reconnection with exponential backoff prevents overwhelming the server while maintaining availability.

import 'dart:async';
import 'dart:math';

class ReconnectingWebSocket {
  WebSocketChannel? _channel;
  final String url;
  StreamSubscription? _subscription;
  bool _shouldReconnect = true;
  int _reconnectAttempts = 0;
  final int _maxReconnectDelay = 30000;
  final int _baseDelay = 1000;
  Timer? _reconnectTimer;

  final StreamController<ConnectionStatus> _statusController =
      StreamController<ConnectionStatus>.broadcast();
  final StreamController<dynamic> _messageController =
      StreamController<dynamic>.broadcast();

  Stream<ConnectionStatus> get statusStream => _statusController.stream;
  Stream<dynamic> get messageStream => _messageController.stream;

  ReconnectingWebSocket(this.url);

  void connect() {
    _shouldReconnect = true;
    _connect();
  }

  void _connect() {
    if (!_shouldReconnect) return;

    _statusController.add(ConnectionStatus.connecting);

    try {
      _channel = WebSocketChannel.connect(Uri.parse(url));

      _subscription = _channel!.stream.listen(
        _handleMessage,
        onError: _handleError,
        onDone: _handleDone,
      );

      _reconnectAttempts = 0;
      _statusController.add(ConnectionStatus.connected);
    } catch (e) {
      _scheduleReconnect();
    }
  }

  void _handleMessage(dynamic message) {
    _messageController.add(message);
  }

  void _handleError(error) {
    _statusController.add(ConnectionStatus.error);
    _scheduleReconnect();
  }

  void _handleDone() {
    _statusController.add(ConnectionStatus.disconnected);
    _scheduleReconnect();
  }

  void _scheduleReconnect() {
    if (!_shouldReconnect) return;

    _reconnectAttempts++;
    final delay = min(
      _baseDelay * pow(2, _reconnectAttempts),
      _maxReconnectDelay,
    ).toInt();

    _reconnectTimer?.cancel();
    _reconnectTimer = Timer(Duration(milliseconds: delay), _connect);
  }

  void sendMessage(String message) {
    _channel?.sink.add(message);
  }

  void disconnect() {
    _shouldReconnect = false;
    _reconnectTimer?.cancel();
    _subscription?.cancel();
    _channel?.sink.close();
    _statusController.add(ConnectionStatus.disconnected);
  }

  void dispose() {
    disconnect();
    _statusController.close();
    _messageController.close();
  }
}

Understanding when to reconnect requires checking WebSocket close codes to determine if the closure was normal or indicates an error condition.

Cross-Platform Considerations

Flutter websocket implementations differ between web and native platforms. The web_socket_channel package handles these differences, but understanding platform-specific behavior helps avoid issues.

Web Platform Limitations

Web applications use the browser’s WebSocket API, which has restrictions:

import 'package:flutter/foundation.dart' show kIsWeb;

class PlatformWebSocket {
  void connect(String url) {
    if (kIsWeb) {
      // Web platform cannot set custom headers
      // Must rely on cookies for authentication
      _connectWeb(url);
    } else {
      // Native platforms support custom headers
      _connectNative(url);
    }
  }

  void _connectWeb(String url) {
    final channel = WebSocketChannel.connect(
      Uri.parse(url),
    );
  }

  void _connectNative(String url) {
    // On native platforms, you have more control
    final channel = WebSocketChannel.connect(
      Uri.parse(url),
      protocols: ['custom-protocol'],
    );
  }
}

Mobile Background Behavior

Mobile platforms suspend background connections. Handle app lifecycle events to maintain connections appropriately:

import 'package:flutter/widgets.dart';

class LifecycleAwareWebSocket extends WidgetsBindingObserver {
  final ReconnectingWebSocket _webSocket;

  LifecycleAwareWebSocket(String url) : _webSocket = ReconnectingWebSocket(url) {
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        _webSocket.connect();
        break;
      case AppLifecycleState.paused:
      case AppLifecycleState.inactive:
        _webSocket.disconnect();
        break;
      case AppLifecycleState.detached:
      case AppLifecycleState.hidden:
        break;
    }
  }

  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _webSocket.dispose();
  }
}

Desktop Considerations

Desktop platforms behave similarly to mobile but without the same background restrictions:

class DesktopWebSocket {
  final ReconnectingWebSocket _webSocket;

  DesktopWebSocket(String url) : _webSocket = ReconnectingWebSocket(url);

  void connect() {
    // Desktop apps can maintain persistent connections
    _webSocket.connect();
  }

  // No need to disconnect on window minimize
  // Connection stays active in background
}

Error Handling

Comprehensive error handling separates robust applications from fragile ones. WebSocket connections fail for many reasons: network issues, server problems, protocol errors, and client bugs.

class ErrorHandlingWebSocket {
  WebSocketChannel? _channel;
  final void Function(WebSocketError) onError;

  ErrorHandlingWebSocket({required this.onError});

  void connect(String url) {
    try {
      _channel = WebSocketChannel.connect(Uri.parse(url));

      _channel!.stream.listen(
        _handleMessage,
        onError: (error) {
          if (error is WebSocketChannelException) {
            onError(WebSocketError(
              type: ErrorType.connection,
              message: 'Connection failed: ${error.message}',
              originalError: error,
            ));
          } else {
            onError(WebSocketError(
              type: ErrorType.unknown,
              message: error.toString(),
              originalError: error,
            ));
          }
        },
        onDone: () {
          onError(WebSocketError(
            type: ErrorType.closed,
            message: 'Connection closed',
          ));
        },
      );
    } on FormatException catch (e) {
      onError(WebSocketError(
        type: ErrorType.invalidUrl,
        message: 'Invalid WebSocket URL',
        originalError: e,
      ));
    } catch (e) {
      onError(WebSocketError(
        type: ErrorType.unknown,
        message: 'Unexpected error: $e',
        originalError: e,
      ));
    }
  }

  void _handleMessage(dynamic message) {
    try {
      if (message is String) {
        final decoded = jsonDecode(message);
        // Process decoded message
      }
    } on FormatException catch (e) {
      onError(WebSocketError(
        type: ErrorType.invalidMessage,
        message: 'Failed to decode message',
        originalError: e,
      ));
    }
  }
}

enum ErrorType {
  connection,
  invalidUrl,
  invalidMessage,
  closed,
  unknown,
}

class WebSocketError {
  final ErrorType type;
  final String message;
  final dynamic originalError;

  WebSocketError({
    required this.type,
    required this.message,
    this.originalError,
  });

  @override
  String toString() => 'WebSocketError($type): $message';
}

Display errors to users in a helpful way:

class ErrorDisplay extends StatelessWidget {
  final WebSocketError error;
  final VoidCallback onRetry;

  const ErrorDisplay({
    required this.error,
    required this.onRetry,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            _getIcon(),
            size: 64,
            color: Colors.red,
          ),
          const SizedBox(height: 16),
          Text(
            _getUserMessage(),
            textAlign: TextAlign.center,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 8),
          Text(
            error.message,
            textAlign: TextAlign.center,
            style: Theme.of(context).textTheme.bodySmall,
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: onRetry,
            child: const Text('Retry'),
          ),
        ],
      ),
    );
  }

  IconData _getIcon() {
    switch (error.type) {
      case ErrorType.connection:
        return Icons.cloud_off;
      case ErrorType.invalidUrl:
        return Icons.link_off;
      case ErrorType.invalidMessage:
        return Icons.error_outline;
      case ErrorType.closed:
        return Icons.power_off;
      case ErrorType.unknown:
        return Icons.warning;
    }
  }

  String _getUserMessage() {
    switch (error.type) {
      case ErrorType.connection:
        return 'Connection failed';
      case ErrorType.invalidUrl:
        return 'Invalid server address';
      case ErrorType.invalidMessage:
        return 'Received invalid data';
      case ErrorType.closed:
        return 'Connection closed';
      case ErrorType.unknown:
        return 'Something went wrong';
    }
  }
}

Production Tips

Deploying WebSocket applications requires attention to security, performance, and reliability. These production considerations ensure your application scales and remains secure.

Secure Connections

Always use wss:// in production:

class SecureWebSocket {
  static String getUrl(String baseUrl, {bool forceSecure = true}) {
    final uri = Uri.parse(baseUrl);

    if (forceSecure && uri.scheme == 'ws') {
      return baseUrl.replaceFirst('ws://', 'wss://');
    }

    return baseUrl;
  }
}

Authentication

Implement token-based authentication:

class AuthenticatedWebSocket {
  Future<void> connect(String url, String token) async {
    // Option 1: Token in URL query parameter
    final uri = Uri.parse(url).replace(
      queryParameters: {'token': token},
    );

    final channel = WebSocketChannel.connect(uri);

    // Option 2: Send token as first message
    channel.sink.add(jsonEncode({
      'type': 'auth',
      'token': token,
    }));
  }
}

Message Queuing

Queue messages when disconnected:

class QueuedWebSocket {
  final List<String> _messageQueue = [];
  WebSocketChannel? _channel;
  bool _isConnected = false;

  void sendMessage(String message) {
    if (_isConnected) {
      _channel?.sink.add(message);
    } else {
      _messageQueue.add(message);
    }
  }

  void _onConnected() {
    _isConnected = true;

    while (_messageQueue.isNotEmpty) {
      final message = _messageQueue.removeAt(0);
      _channel?.sink.add(message);
    }
  }
}

Connection Monitoring

Track connection health with heartbeats:

class HeartbeatWebSocket {
  Timer? _heartbeatTimer;
  Timer? _timeoutTimer;
  final Duration heartbeatInterval = const Duration(seconds: 30);
  final Duration timeout = const Duration(seconds: 10);

  void startHeartbeat() {
    _heartbeatTimer?.cancel();
    _heartbeatTimer = Timer.periodic(heartbeatInterval, (_) {
      sendPing();
      _startTimeout();
    });
  }

  void sendPing() {
    _channel?.sink.add(jsonEncode({'type': 'ping'}));
  }

  void _startTimeout() {
    _timeoutTimer?.cancel();
    _timeoutTimer = Timer(timeout, () {
      // No pong received, reconnect
      _reconnect();
    });
  }

  void handlePong() {
    _timeoutTimer?.cancel();
  }

  void _reconnect() {
    _channel?.sink.close();
    connect();
  }
}

Testing

Proper testing ensures reliability. Learn more about how to test WebSockets for comprehensive testing strategies.

class MockWebSocketChannel implements WebSocketChannel {
  final StreamController<dynamic> _controller = StreamController();
  final MockWebSocketSink _sink = MockWebSocketSink();

  @override
  Stream get stream => _controller.stream;

  @override
  WebSocketSink get sink => _sink;

  void addMessage(String message) {
    _controller.add(message);
  }

  void addError(Object error) {
    _controller.addError(error);
  }

  void close() {
    _controller.close();
  }
}

class MockWebSocketSink implements WebSocketSink {
  final List<dynamic> sentMessages = [];

  @override
  void add(dynamic event) {
    sentMessages.add(event);
  }

  @override
  Future close([int? closeCode, String? closeReason]) async {
    // Mock close
  }

  @override
  Future get done => Future.value();
}

Understanding what is WebSocket helps make better architectural decisions when building Flutter applications.

Frequently Asked Questions

How do I handle WebSocket connections in Flutter web vs mobile?

The web_socket_channel package abstracts platform differences, but be aware that Flutter web uses browser WebSocket APIs which cannot set custom headers. Use cookies for web authentication and custom headers for mobile. Check the platform with kIsWeb and adjust your connection logic accordingly. Mobile apps should handle lifecycle events to disconnect when backgrounded and reconnect when foregrounded.

What is the best way to manage WebSocket state in Flutter?

Use Provider or Riverpod for state management. Create a dedicated notifier or provider that manages the WebSocket connection, connection status, messages, and errors. This centralizes your WebSocket logic and makes it accessible throughout your widget tree. Expose streams for messages and status updates, allowing widgets to rebuild reactively when data changes.

How do I implement automatic reconnection with exponential backoff?

Implement a wrapper class that monitors the connection stream’s onDone and onError callbacks. When the connection closes, schedule a reconnection attempt with a delay that doubles after each failed attempt, capped at a maximum delay (typically 30 seconds). Reset the attempt counter when a connection succeeds. Cancel pending reconnection timers when the user explicitly disconnects.

Should I keep WebSocket connections open when the Flutter app is in the background?

On mobile platforms, close WebSocket connections when the app moves to the background and reconnect when it returns to the foreground. Mobile operating systems suspend background network activity to save battery. Use WidgetsBindingObserver to listen for app lifecycle changes and handle connections appropriately. Desktop applications can maintain persistent connections since they do not face the same background restrictions.