Skip to content
ExponaBLOG
FLUTTER · DAY 060 / 100

Firebase in Flutter apps: a hands-on guide for engineers

The services I use most.

Expona Admin8 min read

Firebase in Flutter apps

I've been shipping production apps for over five years now, and if I'm honest, Firebase has saved me countless hours of backend infrastructure work. When you're building features fast and your team is lean, Firebase in Flutter apps gives you reliable services out of the box—authentication, messaging, crash monitoring, and feature toggles—all integrated through the FlutterFire library. In this article, I'll walk you through the four services I reach for most, why they matter, and how to use them well.

Why Firebase matters in Flutter development

Before we dive into specifics, let me set context. Firebase isn't magic, but it removes boilerplate. You don't have to build authentication servers, manage push notification infrastructure, parse crash logs by hand, or deploy feature flag systems. For startups and small teams especially, that's enormous. I've used Firebase across e-commerce platforms, ride-hailing apps, and SaaS products in production. The trade-off is vendor lock-in and costs at scale, but for most projects, the productivity win outweighs the risk.

Authentication — Email, phone and social login

User authentication is the first thing most apps need, and Firebase Authentication handles it well. I typically start with email and phone options, then add Google or Apple sign-in if the product roadmap calls for it. The FlutterFire integration is solid, and it handles session management, token refresh, and cross-platform consistency.

Here's a basic email sign-up flow:

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

Future<void> signUpWithEmail(String email, String password) async {
  try {
    final userCredential = await FirebaseAuth.instance
        .createUserWithEmailAndPassword(
          email: email,
          password: password,
        );
    print('User created: ${userCredential.user?.uid}');
  } on FirebaseAuthException catch (e) {
    if (e.code == 'weak-password') {
      print('Password too weak');
    } else if (e.code == 'email-already-in-use') {
      print('Email already registered');
    }
  }
}

Phone authentication is equally straightforward. Firebase handles SMS delivery and verification:

dart
Future<void> signInWithPhone(String phoneNumber) async {
  await FirebaseAuth.instance.verifyPhoneNumber(
    phoneNumber: phoneNumber,
    verificationCompleted: (PhoneAuthCredential credential) async {
      await FirebaseAuth.instance.signInWithCredential(credential);
    },
    verificationFailed: (FirebaseAuthException e) {
      print('Verification failed: ${e.message}');
    },
    codeSent: (String verificationId, int? resendToken) {
      // User received SMS, prompt for code
      print('Code sent to $phoneNumber');
    },
    codeAutoRetrievalTimeout: (String verificationId) {},
  );
}

For social login, I usually integrate Google Sign-In and Apple Sign-In. Google is straightforward on Android; Apple requires a bit more ceremony on iOS because of their privacy requirements. Both feed into Firebase Authentication, so you get unified user management.

In production, I always validate email domains if the app is B2B, handle password reset flows gracefully, and make sure the user can update their profile (name, photo) after sign-up. Firebase provides built-in methods for this, so use them.

Cloud Messaging — Push notifications on both platforms

Firebase Cloud Messaging (FCM) is how I send push notifications to millions of devices without managing the infrastructure myself. On Android, FCM handles delivery directly. On iOS, it sits on top of Apple's APNs, but Flutter abstracts that away.

Setting up FCM is a two-step process: configure it in Firebase Console, then handle tokens and messages in your Flutter app.

First, request notification permissions and get the device token:

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

Future<void> initializeMessaging() async {
  final messaging = FirebaseMessaging.instance;
  
  // Request permission (iOS only)
  NotificationSettings settings = await messaging.requestPermission(
    alert: true,
    badge: true,
    sound: true,
  );
  
  if (settings.authorizationStatus == AuthorizationStatus.authorized) {
    // Get the device token and send it to your backend
    final token = await messaging.getToken();
    print('Device token: $token');
    
    // Send this token to your backend for later use
  }
}

Next, handle incoming messages. FCM sends messages in three states: when the app is in the foreground, background, or terminated. I typically set up handlers for all three:

dart
Future<void> setupMessageHandlers() async {
  final messaging = FirebaseMessaging.instance;
  
  // Foreground messages (app is open)
  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    print('Message received in foreground: ${message.notification?.title}');
    // Show a snackbar or in-app notification
  });
  
  // Background/Terminated messages (user taps notification)
  FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
    print('User tapped notification: ${message.data}');
    // Navigate to relevant screen
  });
  
  // Handle background message (runs in isolate)
  FirebaseMessaging.onBackgroundMessage(_backgroundMessageHandler);
}

Future<void> _backgroundMessageHandler(RemoteMessage message) async {
  print('Handling background message: ${message.messageId}');
  // Process the message, update local database, etc.
}

In production, I segment users by topics and send targeted messages. You can subscribe users to topics server-side, then publish messages to those topics:

dart
// Subscribe to a topic
await FirebaseMessaging.instance.subscribeToTopic('order_updates');

// Unsubscribe when needed
await FirebaseMessaging.instance.unsubscribeFromTopic('order_updates');

Then from your backend (Node.js, .NET, Python, etc.), you send messages to that topic. This is cleaner than maintaining a database of device tokens.

One thing I always do: test on a real device. Emulators don't reliably receive FCM messages, and I've spent hours debugging phantom issues because I didn't test properly on hardware.

Crashlytics — Crash reports with stack traces

Crashlytics captures and reports unhandled exceptions and crashes from your live app. It aggregates them by stack trace, shows you the frequency and affected user counts, and integrates with your Firebase Console. I've caught critical bugs hours after deployment using Crashlytics.

Setup is minimal:

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

Future<void> initializeCrashlytics() async {
  // Pass all uncaught errors from the Flutter framework to Crashlytics
  FlutterError.onError = (errorDetails) {
    FirebaseCrashlytics.instance.recordFlutterError(errorDetails);
  };
  
  // Pass all uncaught errors from async code to Crashlytics
  PlatformDispatcher.instance.onError = (error, stack) {
    FirebaseCrashlytics.instance.recordError(error, stack);
    return true;
  };
}

Call this in your main function before running the app. From there, Crashlytics automatically captures crashes and sends them to Firebase.

You can also manually log errors when you catch exceptions:

dart
try {
  // Some operation
} catch (e, stackTrace) {
  await FirebaseCrashlytics.instance.recordError(e, stackTrace);
}

And you can add custom logs to provide context:

dart
FirebaseCrashlytics.instance.log('User opened cart screen');
FirebaseCrashlytics.instance.setUserIdentifier('user_123');
FirebaseCrashlytics.instance.setCustomKey('order_id', '456');

In production, I enable Crashlytics in release builds only, not in debug. That keeps the noise down and lets you focus on real issues.

Remote Config — Change behavior without releasing

Remote Config lets you change app behavior by updating configuration values in Firebase Console without shipping a new app version. I use this for feature flags, A/B tests, and toggling features during incidents.

Set up Remote Config like this:

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

Future<void> initializeRemoteConfig() async {
  final remoteConfig = FirebaseRemoteConfig.instance;
  
  // Set default values (fallback if remote fetch fails)
  await remoteConfig.setDefaults({
    'new_checkout_flow': false,
    'max_cart_items': 50,
    'discount_percentage': 0.0,
  });
  
  // Fetch and activate remote config
  try {
    await remoteConfig.fetchAndActivate();
    print('Remote config updated');
  } catch (e) {
    print('Failed to fetch remote config: $e');
  }
}

Then retrieve values in your app:

dart
bool isNewCheckoutEnabled() {
  return FirebaseRemoteConfig.instance.getBool('new_checkout_flow');
}

int getMaxCartItems() {
  return FirebaseRemoteConfig.instance.getInt('max_cart_items');
}

if (isNewCheckoutEnabled()) {
  // Show new checkout UI
} else {
  // Show legacy checkout UI
}

For feature flags, I typically wrap the logic in a service so I can switch implementations easily:

dart
class FeatureFlags {
  static bool get useNewCheckout {
    return FirebaseRemoteConfig.instance.getBool('new_checkout_flow');
  }
  
  static bool get enableLiveChat {
    return FirebaseRemoteConfig.instance.getBool('enable_live_chat');
  }
}

// In your UI
if (FeatureFlags.useNewCheckout) {
  return NewCheckoutScreen();
} else {
  return LegacyCheckoutScreen();
}

In production, I refresh Remote Config periodically (usually every hour or on app launch) so changes propagate without forcing a new release. I've also used Remote Config to kill off deprecated API endpoints, adjust timeouts during incidents, and roll back problematic features instantly.

Common mistakes

  • Not handling Firebase exceptions explicitly. FirebaseAuthException, FirebaseMessagingException, and others have specific error codes. Catch them and show meaningful messages to users instead of generic errors.
  • Storing sensitive data in Remote Config. Remote Config is client-accessible. Never put API keys, secrets, or sensitive user data there. Use it only for configuration and feature flags.
  • Ignoring FCM permission denial on iOS. If users deny notification permission, your FCM token won't work. Always check AuthorizationStatus and handle the denied case gracefully.
  • Testing Crashlytics only in production. Set up Crashlytics in debug builds and throw test exceptions to verify the pipeline works before relying on it in production.
  • Not cleaning up message listeners. If you set up FirebaseMessaging listeners in a widget, unsubscribe when the widget is disposed to avoid memory leaks.

Key takeaways

  • Firebase Authentication handles email, phone, and social login seamlessly across platforms; use specific error codes to guide users through failures.
  • Firebase Cloud Messaging powers push notifications at scale; test on real devices, segment users by topics, and handle foreground, background, and terminated states separately.
  • Crashlytics automatically captures crashes and aggregates them by stack trace; manually log errors and add context logs to aid debugging.
  • Remote Config lets you toggle features, run A/B tests, and roll back changes without releasing a new version; treat it as read-only config, never sensitive data.
  • Set up all four services early in your project; the cost is low, the benefit is high, and integration is straightforward with FlutterFire.
---

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