Flutter VoIP Apps Are Broken Without This One Trick — The Cross-Isolate Queue Fix

Plagued by missed VoIP calls, delayed push notifications, or broken call states in your Flutter app? You’re definitely not alone

Flutter VoIP Apps Are Broken Without This One Trick — The Cross-Isolate Queue Fix

Flutter VoIP Apps Are Broken Without This One Trick — The Cross-Isolate Queue Fix That Changes Everything!

Struggling with missed calls, delayed notifications, or broken call sessions in your Flutter VoIP app? You’re not alone. Many developers overlook the critical role of cross-isolate communication in handling background events, especially with Firebase and real-time calling. In this deep dive, I reveal how leveraging a persistent cross-isolate queue can rescue your app’s reliability — and why it’s a game-changer for seamless VoIP functionality in Flutter. Whether you’re using Firebase Cloud Messaging, handling call events, or just tired of inconsistent user experiences, this is the solution you’ve been searching for.

Backstory: The Hidden Pitfalls of Isolate Communication in Flutter VoIP Apps

Recently, I was tasked with developing a Flutter-based VoIP calling app that could receive WebRTC-backed calls with the same feel and reliability as traditional phone calls. Given my extensive background in WebRTC development — and as the proud author of the flutter_janus_client library, which we planned to use—I felt confident about handling the WebRTC side of things with ease.

However, what I hadn’t tackled before was integrating calling behavior into a Flutter app that would work even when the app was in the background or killed state. Still, I was optimistic that I’d eventually figure it out.

I vaguely remembered a library called callkeep (by flutter_webrtc), which leverages Android's ConnectionService and iOS's CallKit to offer native call management features for VoIP apps. It seemed like the perfect fit to simulate the system call UI and behavior.

After a few days of experimentation and refactoring, I got incoming calls working reliably in the foreground. However, the real problem started when a call was received while the phone was locked or the app was killed. Due to Flutter’s isolate-based architecture, I was unable to notify the UI isolate to kick off the WebRTC connection logic — meaning the call would show up on the system UI, but the actual media (audio/video) connection wouldn’t initialize.

This was a frustrating and eye-opening limitation caused by the nature of isolate separation in Dart/Flutter. Without a persistent communication mechanism between isolates, especially during background or terminated states, the app simply couldn’t perform as expected.

Cross-Isolate Queue to the Rescue!

My approach to solving this problem was to capture events from the background isolate — in my case, from the Firebase Cloud Messaging (FCM) background handler — and replay those saved events once the Flutter UI isolate became active again. To make this reliable, I also wanted an acknowledgement mechanism, similar to how Apache Pulsar or Kafka handle message acknowledgements, to ensure events weren’t processed more than once.

With this idea in mind, I started searching for an existing solution on pub.dev, hoping someone had already built something like this. But after coming up empty-handed, I realised it was time to take matters into my own hands.

That’s when I decided to create and publish a package called cross_isolate_messenger, specifically designed to handle persistent, ack-based message passing between isolates in Flutter.

How It Works: Code Walkthrough

import 'package:cross_isolate_messenger/cross_isolate_messenger.dart'; 
Future<void> notifyQueue({ 
  required String messageId, 
  bool isLocal = false, 
  required PersistentQueue<AppQueueData> queue, 
  Map<String, dynamic>? data, 
  required CallStatus callStatus, 
}) async { 
  if (isLocal) { 
    queue.send( 
      AppQueueData.fromJson({ 
        ...data!, 
        'id': messageId, 
        'origin': 'foreground', 
        'callStatus': callStatus.value, 
      }), 
    ); 
  } else { 
    await queue.send( 
      AppQueueData.fromJson({ 
        ...data!, 
        'id': messageId, 
        'origin': 'background', 
        'callStatus': callStatus.value, 
      }), 
    ); 
  } 
} 
 
// used freezed_annotation: ^2.4.4 
@freezed 
class AppQueueData with _$AppQueueData { 
  const factory AppQueueData({ 
    required String id, 
    required String origin, 
    required String callId, 
    required String state, 
    @Default(CallStatus.idle) CallStatus callStatus, 
    String? clientName, 
    String? callerName, 
    String? callPin, 
    String? token, 
    String? endpoint, 
  }) = _AppQueueData; 
 
  factory AppQueueData.fromJson(Map<String, dynamic> json) => 
      _$AppQueueDataFromJson(json); 
} 
 
@pragma('vm:entry-point') 
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async { 
  final queue = await PersistentQueue.getInstance<AppQueueData>( 
    AppQueueData.fromJson, 
    (msg) => msg.toJson(), 
  ); 
   final callState = message.data['state']; 
    final callId = message.data['callId']; 
    final callerName = message.data['callerName']; 
  if (callState == 'started') { 
      logger.debug('fcm isolate call started event'); 
      await callKeep.displayIncomingCall( 
        number: callerName, 
        callUUID: callId, 
        callerName: callerName, 
        additionalData: message.data, 
      ); 
      await callKeep.backToForeground(); 
      Future.delayed(Duration(milliseconds: 500)).then((v) async { 
        await notifyQueue( 
          queue: queue, 
          data: message.data, 
          messageId: 'fcm_ringing_event', 
          callStatus: CallStatus.ringing, 
        ); 
      }); 
    } 
   
} 
 
void main() async { 
  await firebaseMessagingCubit.initFirebaseMessaging(); 
  FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); 
  final queue = await PersistentQueue.getInstance<AppQueueData>( 
    AppQueueData.fromJson, 
    (msg) => msg.toJson(), 
  ); 
  queue.bindUIIsolate(); 
  queue.stream.listen((data) async { 
    // only send notification for call if user is loggedIn 
    if (authCubit.state.isLoggedIn) { 
      print('ui queue: $data'); 
      // here we will recieve events from both foreground and background to update our callCubit or call bloc. 
 
      await queue.ack(data.id); 
    } 
  }); 
 
  runApp(...) 
   
}

Let’s break down what’s happening logically:

  1. Creating a Shared Queue Between Isolates

The app uses a persistent queue (PersistentQueue<AppQueueData>) powered by the cross_isolate_messenger package. This queue acts as a shared channel that both isolates can write to and read from, even across app lifecycle states (foreground, background, or killed).

2. Handling Background FCM Messages

  • When a VoIP call notification is received in the background (via FCM), the firebaseMessagingBackgroundHandler is triggered. Here:
  • It initialises the shared queue.
  • It extracts important data like the call state and caller ID.
  • If the call has started, it displays the native call UI using CallKeep, then writes a structured message (AppQueueData) to the queue using the notifyQueue function.

3. Writing to the Queue

The notifyQueue function takes care of sending messages to the queue with metadata like the call status, source isolate (foreground or background), and other call details. This ensures the main UI isolate can understand and react to the event accordingly.

4. Bootstrapping in main()

During app startup (main()), the queue is initialized and bound to the UI isolate using queue.bindUIIsolate(). This ensures that once the UI isolate comes alive, it can receive and process any queued events sent while the app was in the background or killed state.

in queue.stream.listen we recieve events from both background and foreground isolates as a central place where we can update our bloc or cubit or any state management for call logic so ui can be updated accordingly

Closing Notes

While this article didn’t dive into the full implementation of a complete VoIP calling flow, that’s a journey I’d love to take you on in a separate piece. If you found this helpful, give it a clap! 🙌 And if you’d like me to start a full-blown series on building a complete end-to-end VoIP app from scratch in Flutter, drop a comment below with #we_need_voip_series — let’s make it happen! 🚀