Development

Building a Real-Time Chat App with Flutter and WebSockets

Muhammad Shakil Muhammad Shakil
Mar 31, 2026
14 min read
Building a Real-Time Chat App with Flutter and WebSockets
Back to Blog

I've built three different chat apps over the past two years. The first one used Firebase Realtime Database — it worked, but the latency felt sluggish and I couldn't customize the protocol at all. The second used REST polling every 2 seconds, which was... let's just say my users' battery life didn't appreciate it. The third one? WebSockets. And honestly, I should've started there.

WebSockets give you something no other approach can match: a persistent, bidirectional connection between your Flutter app and the server. Messages arrive instantly. No polling. No wasted bandwidth. Just a clean pipe that stays open until you close it. If you've ever wondered how WhatsApp or Telegram deliver messages so fast, this is the core technology behind it.

This guide walks you through building a real-time chat app from scratch using Flutter and the web_socket_channel package. We'll cover connection management, building a polished chat UI, handling disconnections gracefully, and the production gotchas that tutorials usually skip.

📚 What You Will Learn

Set up WebSocket connections in Flutter using web_socket_channel, build a production-quality chat UI with message bubbles and timestamps, handle real-time message streaming with Dart Streams, implement auto-reconnection with exponential backoff, add user authentication and presence indicators, and deploy with proper security best practices.

🔧 Prerequisites

Flutter 3.x installed, basic understanding of Flutter state management, familiarity with async/await and Dart Streams, and a WebSocket server (we'll use a simple Node.js example).

Why WebSockets Beat REST Polling for Chat Apps

Let's get this out of the way: you can build a chat app with REST APIs and periodic polling. But you shouldn't. Here's why.

With REST polling, your app sends an HTTP request to the server every few seconds asking "any new messages?" Most of the time, the answer is no. You're burning battery, wasting data, and adding server load for nothing. Even at 2-second intervals, you've got up to 2 seconds of latency before a message shows up — which feels terrible in a conversation.

WebSockets flip the model. Instead of asking repeatedly, you open a single TCP connection and keep it alive. The server pushes messages the instant they arrive. No wasted requests. No polling delay. Your app goes from "check-wait-check" to "always listening."

The Numbers That Matter

Server-Sent Events (SSE) sit somewhere in between — the server can push data to the client, but the client can't send through the same channel. For chat, you need bidirectional communication. WebSockets are the right tool.

Setting Up Your Flutter WebSocket Project

Start by adding the dependencies you'll need. The web_socket_channel package handles the WebSocket protocol across all Flutter platforms — iOS, Android, web, and desktop.

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  web_socket_channel: ^3.0.1
  json_annotation: ^4.9.0
  uuid: ^4.5.1
  intl: ^0.19.0

dev_dependencies:
  build_runner: ^2.4.13
  json_serializable: ^6.8.0

Run flutter pub get and let's define our message model. Every chat message needs a sender, content, timestamp, and a unique ID for deduplication.

// lib/models/chat_message.dart
import 'package:uuid/uuid.dart';

class ChatMessage {
  final String id;
  final String senderId;
  final String senderName;
  final String content;
  final DateTime timestamp;
  final MessageStatus status;

  ChatMessage({
    String? id,
    required this.senderId,
    required this.senderName,
    required this.content,
    DateTime? timestamp,
    this.status = MessageStatus.sending,
  })  : id = id ?? const Uuid().v4(),
        timestamp = timestamp ?? DateTime.now();

  Map<String, dynamic> toJson() => {
    'id': id,
    'senderId': senderId,
    'senderName': senderName,
    'content': content,
    'timestamp': timestamp.toIso8601String(),
  };

  factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
    id: json['id'],
    senderId: json['senderId'],
    senderName: json['senderName'],
    content: json['content'],
    timestamp: DateTime.parse(json['timestamp']),
    status: MessageStatus.delivered,
  );
}

enum MessageStatus { sending, sent, delivered, failed }

Notice the MessageStatus enum. This is something most tutorials skip, but users expect to see whether their message was sent, delivered, or failed. We'll use this to show checkmarks or retry buttons in the UI.

Building a WebSocket Service Class

Don't scatter WebSocket logic across your widgets. Wrap everything in a service class that manages the connection lifecycle. This follows the same clean architecture principles you'd use for any other data source.

// lib/services/chat_service.dart
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
import '../models/chat_message.dart';

class ChatService {
  WebSocketChannel? _channel;
  final _messageController = StreamController<ChatMessage>.broadcast();
  final _connectionController = StreamController<bool>.broadcast();
  bool _isConnected = false;

  Stream<ChatMessage> get messages => _messageController.stream;
  Stream<bool> get connectionState => _connectionController.stream;
  bool get isConnected => _isConnected;

  Future<void> connect(String url, {String? token}) async {
    try {
      final uri = token != null
          ? Uri.parse('$url?token=$token')
          : Uri.parse(url);

      _channel = WebSocketChannel.connect(uri);
      await _channel!.ready;

      _isConnected = true;
      _connectionController.add(true);

      _channel!.stream.listen(
        (data) {
          final json = jsonDecode(data as String);
          final message = ChatMessage.fromJson(json);
          _messageController.add(message);
        },
        onError: (error) {
          _isConnected = false;
          _connectionController.add(false);
        },
        onDone: () {
          _isConnected = false;
          _connectionController.add(false);
        },
      );
    } catch (e) {
      _isConnected = false;
      _connectionController.add(false);
      rethrow;
    }
  }

  void sendMessage(ChatMessage message) {
    if (_isConnected && _channel != null) {
      _channel!.sink.add(jsonEncode(message.toJson()));
    }
  }

  void dispose() {
    _channel?.sink.close();
    _messageController.close();
    _connectionController.close();
  }
}

A few things to notice. The _messageController is a broadcast stream — multiple widgets can listen simultaneously. We track connection state separately so the UI can show "connecting..." banners. And the ready future lets us await the actual WebSocket handshake before marking the connection as live.

This service becomes the single source of truth for your chat. Whether you're using Bloc, Riverpod, or any state management, your state layer wraps this service and exposes the streams to widgets.

Designing the Chat UI That Actually Feels Native

Here's where most WebSocket tutorials completely fall apart — they show you the connection code and then slap messages into a basic ListView. Your users deserve better than that.

A good chat UI needs message bubbles with tails, proper alignment (sent right, received left), timestamps that group intelligently, scroll-to-bottom behavior, and a text input that expands with content. Let's build it.

// lib/widgets/chat_screen.dart
class ChatScreen extends StatefulWidget {
  final ChatService chatService;
  final String currentUserId;

  const ChatScreen({
    super.key,
    required this.chatService,
    required this.currentUserId,
  });

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

class _ChatScreenState extends State<ChatScreen> {
  final _messages = <ChatMessage>[];
  final _scrollController = ScrollController();
  final _textController = TextEditingController();
  late final StreamSubscription _messageSub;

  @override
  void initState() {
    super.initState();
    _messageSub = widget.chatService.messages.listen((msg) {
      setState(() => _messages.add(msg));
      _scrollToBottom();
    });
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 200),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            controller: _scrollController,
            padding: const EdgeInsets.symmetric(
              horizontal: 12, vertical: 8,
            ),
            itemCount: _messages.length,
            itemBuilder: (context, index) {
              final msg = _messages[index];
              final isMine = msg.senderId == widget.currentUserId;
              return MessageBubble(
                message: msg,
                isMine: isMine,
              );
            },
          ),
        ),
        _buildInput(),
      ],
    );
  }

  Widget _buildInput() {
    return Container(
      padding: const EdgeInsets.all(8),
      decoration: BoxDecoration(
        color: Theme.of(context).scaffoldBackgroundColor,
        boxShadow: [
          BoxShadow(
            offset: const Offset(0, -1),
            blurRadius: 4,
            color: Colors.black12,
          ),
        ],
      ),
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: _textController,
              maxLines: 4,
              minLines: 1,
              decoration: const InputDecoration(
                hintText: 'Type a message...',
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.all(Radius.circular(24)),
                ),
                contentPadding: EdgeInsets.symmetric(
                  horizontal: 16, vertical: 10,
                ),
              ),
            ),
          ),
          const SizedBox(width: 8),
          IconButton.filled(
            onPressed: _sendMessage,
            icon: const Icon(Icons.send),
          ),
        ],
      ),
    );
  }

  void _sendMessage() {
    final text = _textController.text.trim();
    if (text.isEmpty) return;

    final message = ChatMessage(
      senderId: widget.currentUserId,
      senderName: 'Me',
      content: text,
    );

    widget.chatService.sendMessage(message);
    setState(() => _messages.add(message));
    _textController.clear();
    _scrollToBottom();
  }

  @override
  void dispose() {
    _messageSub.cancel();
    _scrollController.dispose();
    _textController.dispose();
    super.dispose();
  }
}

The _scrollToBottom() method uses addPostFrameCallback to wait until the new message is rendered before scrolling. Without this, you'll scroll to the second-to-last message because the list hasn't updated yet. It's one of those subtle things that separates polished apps from hacky prototypes.

Real-Time Message Handling with Dart Streams

Dart Streams are the backbone of WebSocket handling in Flutter. If you're not comfortable with Streams yet, this is where it all clicks. The WebSocketChannel exposes a Stream for incoming messages and a StreamSink for outgoing ones.

But raw streams aren't always enough. In production, you'll want to transform, filter, and batch messages. Here's how to build a more sophisticated message pipeline.

// Advanced message pipeline with deduplication
class MessagePipeline {
  final _seenIds = <String>{};

  Stream<ChatMessage> process(Stream<dynamic> rawStream) {
    return rawStream
        .map((data) => jsonDecode(data as String))
        .map((json) => ChatMessage.fromJson(json))
        .where((msg) {
          // Deduplicate — server might resend after reconnect
          if (_seenIds.contains(msg.id)) return false;
          _seenIds.add(msg.id);
          return true;
        })
        .handleError((error) {
          debugPrint('Message parse error: $error');
        });
  }
}

The deduplication step is critical. When your app reconnects after a brief disconnection, the server might replay recent messages. Without tracking message IDs, your users see duplicates. This is the kind of production issue that doesn't show up in development but ruins the user experience in the real world.

For performance-critical scenarios, you can add a throttle transformer that batches messages arriving within 16ms (one frame) into a single UI update. This prevents your ListView from rebuilding 100 times per second during message bursts.

Handling Disconnections and Auto-Reconnect

This is the section that separates toy projects from production apps. WebSocket connections will drop. It's not a question of if — it's when. Users switch between WiFi and cellular. They walk through dead zones. Servers restart. Your app needs to handle all of this invisibly.

// lib/services/reconnecting_chat_service.dart
import 'dart:math';

class ReconnectingChatService extends ChatService {
  final String _url;
  final String? _token;
  int _retryCount = 0;
  Timer? _retryTimer;
  final _pendingMessages = <ChatMessage>[];

  ReconnectingChatService(this._url, {String? token})
      : _token = token;

  @override
  Future<void> connect(String url, {String? token}) async {
    try {
      await super.connect(url, token: token);
      _retryCount = 0;
      _flushPendingMessages();
    } catch (e) {
      _scheduleReconnect();
    }
  }

  void _scheduleReconnect() {
    final delay = _calculateBackoff();
    _retryTimer?.cancel();
    _retryTimer = Timer(delay, () => connect(_url, token: _token));
  }

  Duration _calculateBackoff() {
    // Exponential backoff with jitter
    final baseDelay = min(pow(2, _retryCount).toInt(), 30);
    final jitter = Random().nextDouble() * baseDelay * 0.3;
    _retryCount++;
    return Duration(seconds: baseDelay) +
        Duration(milliseconds: (jitter * 1000).toInt());
  }

  @override
  void sendMessage(ChatMessage message) {
    if (isConnected) {
      super.sendMessage(message);
    } else {
      _pendingMessages.add(message);
    }
  }

  void _flushPendingMessages() {
    for (final msg in _pendingMessages) {
      super.sendMessage(msg);
    }
    _pendingMessages.clear();
  }

  @override
  void dispose() {
    _retryTimer?.cancel();
    super.dispose();
  }
}

The exponential backoff with jitter is important. Without jitter, if your server goes down and 10,000 clients reconnect simultaneously, they'll all retry at the exact same intervals — creating a thundering herd that could crash your server again. The random jitter spreads reconnection attempts across a time window.

The pending message queue ensures nothing gets lost during brief disconnections. When the connection comes back, _flushPendingMessages() sends everything that was queued. Your users type a message, see it "sending...", and it delivers the moment the connection restores. They might never even notice the disconnection happened.

Authentication, Presence, and Typing Indicators

A chat app without authentication is just an anonymous shout-box. You need to know who's sending what, and your users need to know who's online.

The cleanest way to authenticate WebSocket connections is to pass a JWT token during the initial handshake. Your server validates the token before accepting the connection. If it's expired or invalid, the server rejects the upgrade — no malformed messages ever reach your app.

// Connecting with authentication
final chatService = ReconnectingChatService(
  'wss://your-server.com/chat',
  token: await authService.getAccessToken(),
);

// Sending presence and typing events
void _handleTyping() {
  chatService.sendEvent({
    'type': 'typing',
    'userId': currentUser.id,
    'roomId': currentRoom.id,
  });

  // Debounce — don't spam typing events
  _typingTimer?.cancel();
  _typingTimer = Timer(
    const Duration(seconds: 2),
    () => chatService.sendEvent({
      'type': 'stop_typing',
      'userId': currentUser.id,
      'roomId': currentRoom.id,
    }),
  );
}

For presence (showing who's online), the server tracks connected users and broadcasts presence updates. When a user connects, the server sends a "user_joined" event. When the WebSocket closes, the server waits 10–15 seconds before broadcasting "user_left" — that grace period prevents false offline indicators during brief reconnections.

Typing indicators follow the same pattern. Send a "typing" event when the user starts typing, debounce it, and send "stop_typing" after 2 seconds of inactivity. The debounce is critical — without it, every keystroke fires a WebSocket event, which is wasteful and annoying for other clients receiving 30 "typing" notifications per second.

If you're considering Firebase or Supabase for the backend, both offer real-time presence features out of the box. But with raw WebSockets, you get full control over the protocol and can add features like push notifications for offline users through FCM.

Production Deployment Tips

Here's what I wish someone had told me before I shipped my first WebSocket-powered app.

Use wss:// everywhere

Always use wss:// (TLS-encrypted) connections. Some mobile carriers and corporate firewalls actively block or interfere with unencrypted ws:// traffic. Plus, iOS requires App Transport Security compliance, which means HTTPS/WSS by default.

Implement heartbeats

Send a ping message every 30 seconds from the client. If you don't get a pong within 10 seconds, consider the connection dead and trigger reconnection. Some firewalls and load balancers silently kill idle connections after 60 seconds — heartbeats keep them alive.

// Heartbeat to keep connection alive
void _startHeartbeat() {
  _heartbeatTimer = Timer.periodic(
    const Duration(seconds: 30),
    (_) {
      if (isConnected) {
        _channel?.sink.add(jsonEncode({'type': 'ping'}));
        _pongTimer = Timer(const Duration(seconds: 10), () {
          // No pong received — connection is dead
          _channel?.sink.close();
          _scheduleReconnect();
        });
      }
    },
  );
}

// Cancel pong timer when we receive a pong
void _handlePong() {
  _pongTimer?.cancel();
}

Message persistence

Store messages locally using SQLite or Drift. When the app restarts, load the last 50 messages from local storage immediately instead of waiting for the server. This gives the impression of instant loading even on slow connections.

Rate limiting

Implement client-side rate limiting to prevent abuse. Cap messages at 10 per second per user. Your server should enforce this too, but client-side limits prevent accidental floods from buggy code.

Testing WebSocket connections

Unit testing WebSocket code is tricky because it's inherently async and stateful. Use the MockWebSocketChannel pattern — inject a fake channel during tests that lets you simulate messages, errors, and disconnections. Test your reconnection logic by triggering close events and verifying the backoff timing.

🔗 Official Resources

Frequently Asked Questions

What's the best WebSocket package for Flutter in 2026?

The web_socket_channel package remains the standard choice for WebSocket connections in Flutter. It's maintained by the Dart team and works across all platforms (iOS, Android, web, desktop). For more advanced needs like automatic reconnection and heartbeat support, the socket_io_client package provides Socket.IO protocol compatibility, which is popular with Node.js backends. Choose web_socket_channel for simple WebSocket connections and socket_io_client when your server uses Socket.IO.

Can Flutter handle thousands of concurrent messages through WebSockets?

Flutter handles WebSocket message throughput very well because Dart's event loop and stream system are designed for asynchronous I/O. The bottleneck is rarely Flutter itself — it's usually the server or network. For high-throughput chat apps, batch UI updates using stream transformers, throttle message rendering to 60fps, and use ListView.builder for efficient list rendering. Production apps like Ably and Stream Chat handle thousands of messages per second using these patterns.

How do I handle WebSocket disconnections in Flutter?

Listen for the WebSocketChannel's stream done event and implement exponential backoff for reconnection. Start with a 1-second delay, double it each attempt up to a maximum of 30 seconds, and add random jitter to prevent thundering herd problems. Use the connectivity_plus package to detect network state changes and trigger reconnection automatically when the device comes back online. Always queue unsent messages locally and flush them after reconnecting.

Should I use WebSockets or Firebase Realtime Database for Flutter chat?

It depends on your scale and team. Firebase Realtime Database is faster to implement — you get real-time sync, offline support, and authentication out of the box with zero backend code. WebSockets give you full control over the protocol, lower latency for high-frequency updates, and no vendor lock-in. For MVPs and small teams, Firebase is usually the better choice. For apps that need custom protocols, E2E encryption, or handle millions of concurrent connections, WebSockets with a custom server are worth the extra effort.

How do I send images and files through WebSockets in Flutter?

Don't send binary files directly through WebSocket connections — it blocks the message stream and wastes bandwidth for other users. Instead, upload files to cloud storage (Firebase Storage, S3, or your own server) using HTTP multipart upload, get back a URL, then send that URL as a regular text message through the WebSocket. The receiving client downloads and caches the file separately. This approach is what WhatsApp, Telegram, and every major chat app uses.

What's the difference between WebSockets and Server-Sent Events for Flutter chat?

WebSockets provide full-duplex communication — both client and server can send messages at any time through a single persistent connection. Server-Sent Events (SSE) are one-directional: only the server pushes data to the client, and the client must use separate HTTP requests to send data back. For chat apps, WebSockets are the clear choice because you need bidirectional messaging. SSE works better for notification feeds, live dashboards, or any scenario where the client mostly receives updates.

How do I secure WebSocket connections in my Flutter chat app?

Always use wss:// (WebSocket Secure) instead of ws:// to encrypt traffic with TLS. Authenticate users before establishing the WebSocket connection by passing a JWT token as a query parameter or in the initial handshake headers. On the server side, validate the token before accepting the connection. Implement rate limiting per user to prevent spam, sanitize all message content to prevent XSS attacks, and add message size limits to prevent memory exhaustion attacks.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.