← Back to posts Cover image for Flutter Real-time Data: When to Use Server-Sent Events (SSE) vs WebSockets

Flutter Real-time Data: When to Use Server-Sent Events (SSE) vs WebSockets

· 12 min read
Chris
By Chris

Real-time data updates are a cornerstone of modern applications, enabling everything from live chat to instant notifications and dynamic dashboards. As Flutter developers, we often find ourselves needing to push data from a server to our clients without constant polling. This is where Server-Sent Events (SSE) and WebSockets come into play, offering distinct approaches to persistent, real-time communication.

But when do you choose one over the other? Let’s demystify these powerful patterns and see them in action with Flutter.

The Problem: When Polling Just Doesn’t Cut It

Imagine you’re building a live sports score app, a stock ticker, or a notification feed. Your immediate thought might be: “I’ll just hit the API every few seconds!” This is called polling.

// A simplified (and inefficient) polling example
Future<void> startPollingScores() async {
  while (true) {
    try {
      final response = await http.get(Uri.parse('https://api.example.com/live-scores'));
      if (response.statusCode == 200) {
        print('New scores: ${response.body}');
        // Update UI with new scores
      }
    } catch (e) {
      print('Polling error: $e');
    }
    await Future.delayed(const Duration(seconds: 5)); // Poll every 5 seconds
  }
}

While simple, polling has significant drawbacks:

  • Latency: Users only see updates at your polling interval. A 5-second delay can feel like an eternity for live data.
  • Resource Inefficiency: Most requests will return no new data, wasting server and client resources (network, battery).
  • Scalability Issues: Many clients polling frequently can overwhelm your server.

This is precisely where persistent connections shine, offering a more efficient and responsive way to deliver real-time data.

Server-Sent Events (SSE): One-Way Street to Real-time

Server-Sent Events provide a unidirectional channel from the server to the client over a standard HTTP connection. Think of it as a continuous stream of events that the server pushes to your app.

When to Use SSE

SSE is ideal for scenarios where:

  • You only need data from the server, not to send data to it.
  • Examples: Live sports scores, stock price updates, news feeds, real-time dashboards, notifications, progress updates for long-running tasks.
  • You need automatic reconnection handling (built-in to the protocol).

Advantages of SSE

  • Simplicity: Built on top of HTTP, making it easier to implement and compatible with existing HTTP infrastructure.
  • Automatic Reconnection: Browsers (and good client libraries) automatically attempt to reconnect if the connection drops.
  • Lower Overhead: Compared to WebSockets, the handshake is simpler.
  • Standard HTTP: Can leverage existing HTTP features like caching, compression, and authentication.

Disadvantages of SSE

  • Unidirectional: Cannot send data back to the server via the same channel. For that, you’d still need separate HTTP requests.
  • Text-only: Primarily designed for UTF-8 encoded text data.
  • Limited Browser Support: (Less relevant for Flutter, but good to know) Older browsers might not support it natively.

SSE in Flutter: Implementing with http

You don’t always need a heavy package for SSE in Flutter! The http package and Dart’s Stream capabilities are often enough. The server needs to send responses with Content-Type: text/event-stream and format messages like data: Your message\n\n.

Let’s imagine a simple server that sends a new random number every second:

// Server-side (example concept, not Flutter code)
// Node.js Express example:
/*
app.get('/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  setInterval(() => {
    res.write(`data: ${Math.random()}\n\n`);
  }, 1000);
});
*/

Now, here’s how your Flutter client can listen to this stream:

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

/// A simple client for Server-Sent Events.
class SseClient {
  final Uri url;
  http.Client? _client;
  StreamSubscription<String>? _subscription;
  final StreamController<String> _eventController = StreamController.broadcast();

  Stream<String> get events => _eventController.stream;

  SseClient(this.url);

  Future<void> connect() async {
    _client = http.Client();
    try {
      final request = http.Request('GET', url)
        ..headers['Accept'] = 'text/event-stream'
        ..headers['Cache-Control'] = 'no-cache';

      final response = await _client!.send(request);

      if (response.statusCode == 200) {
        print('SSE connection established to $url');
        // Listen to the byte stream and decode to string
        _subscription = response.stream
            .transform(utf8.decoder) // Decode bytes to UTF-8 string
            .transform(const LineSplitter()) // Split into lines
            .where((line) => line.startsWith('data: ')) // Filter for data lines
            .map((line) => line.substring(6)) // Extract the data payload
            .listen(
          (data) {
            _eventController.add(data);
          },
          onError: (error) {
            print('SSE Stream Error: $error');
            _eventController.addError(error);
            _reconnect(); // Attempt to reconnect on error
          },
          onDone: () {
            print('SSE Stream Done.');
            _reconnect(); // Attempt to reconnect if stream closes
          },
        );
      } else {
        print('Failed to connect to SSE: ${response.statusCode}');
        _eventController.addError('Failed to connect: ${response.statusCode}');
        _reconnect();
      }
    } catch (e) {
      print('SSE Connection Error: $e');
      _eventController.addError(e);
      _reconnect();
    }
  }

  void _reconnect() {
    print('Attempting to reconnect SSE in 5 seconds...');
    disconnect(); // Clean up existing connection
    Future.delayed(const Duration(seconds: 5), () {
      if (!_eventController.isClosed) { // Only reconnect if not intentionally closed
        connect();
      }
    });
  }

  void disconnect() {
    print('Disconnecting SSE from $url');
    _subscription?.cancel();
    _subscription = null;
    _client?.close();
    _client = null;
  }

  void dispose() {
    disconnect();
    _eventController.close();
  }
}

// Example usage in a Flutter Widget:
// class MySseWidget extends StatefulWidget { ... }
// class _MySseWidgetState extends State<MySseWidget> {
//   late SseClient _sseClient;
//   String _latestEvent = 'No event yet';

//   @override
//   void initState() {
//     super.initState();
//     _sseClient = SseClient(Uri.parse('http://localhost:3000/events')); // Replace with your SSE endpoint
//     _sseClient.connect();
//     _sseClient.events.listen((event) {
//       setState(() {
//         _latestEvent = event;
//       });
//     });
//   }

//   @override
//   void dispose() {
//     _sseClient.dispose();
//     super.dispose();
//   }

//   @override
//   Widget build(BuildContext context) {
//     return Text('Latest SSE Event: $_latestEvent');
//   }
// }

In this example, we:

  1. Use http.Request to set the Accept header.
  2. Get the response.stream which is a Stream<List<int>>.
  3. transform it using utf8.decoder to get a Stream<String>.
  4. transform again with LineSplitter to process line by line.
  5. filter for lines starting with data: and map to extract the actual payload.
  6. Listen to this processed stream and push events to our _eventController.
  7. Added basic manual reconnection logic, as http package doesn’t handle this automatically for raw streams like a browser would for SSE. For robust production use, consider a dedicated package like eventsource if it fits your needs, but the http client approach is very flexible.

WebSockets: The Full-Duplex Powerhouse

WebSockets provide a full-duplex communication channel over a single, long-lived TCP connection. Once established, both the client and server can send messages to each other at any time.

When to Use WebSockets

WebSockets are the go-to choice for scenarios requiring bidirectional, low-latency communication:

  • Real-time chat applications.
  • Multiplayer online games.
  • Collaborative editing tools.
  • Financial trading platforms requiring instant buy/sell orders.
  • Any application where the client also needs to frequently send data to the server in real-time.

Advantages of WebSockets

  • Bidirectional: Both client and server can initiate communication.
  • Low Latency: After the initial handshake, there’s minimal overhead for sending messages.
  • Efficiency: Uses less bandwidth than HTTP polling due to less header data per message.
  • Supports Binary Data: Can send and receive binary data efficiently, crucial for some applications.

Disadvantages of WebSockets

  • More Complex: Requires more intricate connection management and error handling than SSE.
  • Dedicated Server: Requires a WebSocket-specific server-side implementation.
  • No Automatic Reconnection: You typically need to implement reconnection logic yourself (though some libraries provide it).

WebSockets in Flutter: Implementing with web_socket_channel

Flutter provides excellent support for WebSockets through the web_socket_channel package.

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

class WebSocketService {
  final Uri url;
  WebSocketChannel? _channel;
  StreamSubscription? _subscription;
  final StreamController<String> _messageController = StreamController.broadcast();

  Stream<String> get messages => _messageController.stream;

  // Track connection status
  final StreamController<bool> _connectionStatusController = StreamController.broadcast();
  Stream<bool> get connectionStatus => _connectionStatusController.stream;
  bool _isConnected = false;

  WebSocketService(this.url);

  Future<void> connect() async {
    if (_isConnected) return; // Already connected

    print('Attempting to connect to WebSocket: $url');
    _connectionStatusController.add(false);

    try {
      _channel = WebSocketChannel.connect(url);
      _isConnected = true;
      _connectionStatusController.add(true);
      print('WebSocket connected to $url');

      _subscription = _channel!.stream.listen(
        (data) {
          if (data is String) {
            _messageController.add(data);
          } else {
            // Handle binary data if needed, or convert to string
            _messageController.add('Received binary data: $data');
          }
        },
        onError: (error) {
          print('WebSocket Error: $error');
          _messageController.addError(error);
          _isConnected = false;
          _connectionStatusController.add(false);
          _reconnect(); // Attempt to reconnect on error
        },
        onDone: () {
          print('WebSocket Disconnected.');
          _isConnected = false;
          _connectionStatusController.add(false);
          _reconnect(); // Attempt to reconnect on disconnect
        },
      );
    } catch (e) {
      print('WebSocket Connection Failed: $e');
      _isConnected = false;
      _connectionStatusController.add(false);
      _reconnect(); // Attempt to reconnect on initial connection failure
    }
  }

  void _reconnect() {
    print('Attempting to reconnect WebSocket in 5 seconds...');
    disconnect(); // Ensure previous channel is closed
    Future.delayed(const Duration(seconds: 5), () {
      if (!_messageController.isClosed) { // Only reconnect if not intentionally closed
        connect();
      }
    });
  }

  void sendMessage(String message) {
    if (_isConnected && _channel != null) {
      print('Sending message: $message');
      _channel!.sink.add(message);
    } else {
      print('Cannot send message, WebSocket not connected.');
    }
  }

  void disconnect() {
    if (_isConnected || _channel != null) {
      print('Disconnecting WebSocket from $url');
      _subscription?.cancel();
      _subscription = null;
      _channel?.sink.close();
      _channel = null;
      _isConnected = false;
      _connectionStatusController.add(false);
    }
  }

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

// Example usage in a Flutter Widget:
// class MyWebSocketWidget extends StatefulWidget { ... }
// class _MyWebSocketWidgetState extends State<MyWebSocketWidget> {
//   late WebSocketService _webSocketService;
//   String _latestMessage = 'No message yet';
//   bool _connected = false;

//   @override
//   void initState() {
//     super.initState();
//     // Replace with your WebSocket endpoint (ws:// or wss://)
//     _webSocketService = WebSocketService(Uri.parse('ws://localhost:8080/ws'));
//     _webSocketService.connect();

//     _webSocketService.messages.listen((message) {
//       setState(() {
//         _latestMessage = message;
//       });
//     });

//     _webSocketService.connectionStatus.listen((status) {
//       setState(() {
//         _connected = status;
//       });
//     });
//   }

//   @override
//   void dispose() {
//     _webSocketService.dispose();
//     super.dispose();
//   }

//   @override
//   Widget build(BuildContext context) {
//     return Column(
//       children: [
//         Text('Connection Status: ${_connected ? 'Connected' : 'Disconnected'}'),
//         Text('Latest Message: $_latestMessage'),
//         ElevatedButton(
//           onPressed: _connected
//               ? () => _webSocketService.sendMessage('Hello from Flutter!')
//               : null,
//           child: const Text('Send Message'),
//         ),
//       ],
//     );
//   }
// }

Here, we:

  1. Use WebSocketChannel.connect to establish the connection.
  2. Listen to the _channel.stream for incoming messages.
  3. Use _channel.sink.add to send messages to the server.
  4. Implement manual reconnection logic similar to SSE, as web_socket_channel doesn’t provide it out-of-the-box.
  5. Track connection status, which is crucial for UI feedback and enabling/disabling send functionality.

SSE vs. WebSockets: A Quick Comparison

FeatureServer-Sent Events (SSE)WebSockets
DirectionUnidirectional (Server -> Client)Bidirectional (Server <-> Client)
ProtocolHTTP/1.1WebSocket Protocol (over TCP, HTTP handshake)
Data TypeText (UTF-8)Text & Binary
OverheadLower (simpler handshake)Higher (more complex handshake)
ComplexitySimpler client & server setupMore complex client & server setup
Use CasesNews feeds, stock tickers, live scores, notifications, progress updatesChat, gaming, collaborative editing, real-time dashboards with user input
Auto ReconnectBuilt-in (for native browser clients, manual for http package in Flutter)Manual implementation required
Server Req.Standard HTTP server with specific headersDedicated WebSocket server

Best Practices & Common Pitfalls

  1. Connection Management is Key:

    • Dispose Correctly: Always close your SSE or WebSocket connections when they are no longer needed (e.g., when a widget is disposed). Forgetting to do so leads to memory leaks and unnecessary network activity.
    • Reconnection Logic: As shown in the examples, implement robust reconnection strategies with exponential backoff to handle temporary network issues gracefully.
    • Connection Status: Provide visual feedback to the user about the connection status (e.g., “Connecting…”, “Connected”, “Disconnected”).
  2. Error Handling:

    • Listen to Errors: Always attach onError callbacks to your streams to catch and log issues.
    • Server-Side Errors: Ensure your server sends meaningful error messages or closes connections cleanly to avoid client-side hangs.
  3. Data Parsing:

    • JSON is Your Friend: Most real-time data will be JSON. Use jsonDecode to parse incoming messages.
    • Model Mapping: Map incoming JSON to Dart models for type safety and easier access.
    • Efficient Decoding: For high-volume streams, ensure your decoding logic is efficient.
  4. State Management:

    • Integrate your real-time data streams with your chosen state management solution (Provider, BLoC, Riverpod, etc.). This ensures your UI updates reactively.
    • Avoid calling setState directly in stream listeners in deeply nested widgets; instead, propagate data through your state management.
  5. Security:

    • Always use https:// for SSE and wss:// for WebSockets in production. This encrypts your data in transit.
    • Implement proper authentication and authorization on your server to prevent unauthorized access to your real-time data streams.
  6. Resource Throttling/Backpressure:

    • If your server sends data faster than your client can process it, you might experience issues. Consider server-side throttling or client-side buffering/debouncing for high-volume streams.
  7. Choose Wisely: Don’t just pick WebSockets because they’re “more powerful.” If you only need one-way communication, SSE is simpler, often more robust (due to HTTP’s nature), and easier to scale. Only opt for WebSockets when genuine bidirectional communication is a requirement.

Wrapping Up

Both Server-Sent Events and WebSockets are invaluable tools for building responsive, real-time Flutter applications. By understanding their core differences and knowing when to apply each, you can make informed decisions that lead to more efficient, scalable, and delightful user experiences.

Remember, the goal isn’t always to use the “most advanced” technology, but the right technology for the problem at hand. Happy coding!

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Building Fluid & Interactive UIs in Flutter: Beyond Basic Animations with Custom Painters and Game-Inspired Techniques

Building Fluid & Interactive UIs in Flutter: Beyond Basic Animations with Custom Painters and Game-Inspired Techniques

This post will guide developers through creating highly dynamic and visually rich user interfaces using advanced Flutter techniques like CustomPainter, TickerProviderStateMixin, and even drawing inspiration from game development libraries like Flame for effects. We'll explore how to achieve smooth, interactive animations and reactive UIs that feel truly "liquid" without necessarily building a game.

Cover image for Mastering Responsive & Adaptive Layouts in Flutter: Beyond `MediaQuery`

Mastering Responsive & Adaptive Layouts in Flutter: Beyond `MediaQuery`

This post will guide developers through building truly adaptive Flutter UIs that seamlessly adjust to different screen sizes, orientations, and platforms. We'll cover advanced techniques using `LayoutBuilder`, `CustomMultiChildLayout`, and `Breakpoints` to create flexible, maintainable layouts, moving beyond basic `MediaQuery` checks.