skip to content

A firebase_messaging background handler runs in debug but never seems to run in an Android release build of the Flutter app; what do you check?

level: seniorimportance: should knowfreq 40%

answer

  1. release means AOT and tree shaking
  2. @pragma('vm:entry-point')
  3. top-level, never a closure
  4. fresh isolate: initializeApp again
  5. errors are caught and only printed

basics

~20 s

Check that the handler is a top-level function annotated @pragma('vm:entry-point') so release tree shaking keeps it, registered in main(), that it calls Firebase.initializeApp in its own isolate, and that its errors, which the plugin only prints, are reported.

solid answer

~50 s

In release, Flutter compiles ahead of time and tree-shakes code nothing in Dart calls. The background handler is invoked only from native code, so without `@pragma('vm:entry-point')` it can be removed; the docs require the annotation from Flutter 3.3.0. It must be a named top-level function registered with `FirebaseMessaging.onBackgroundMessage` in `main()`, not a closure or instance method. On Android it runs in a **separate isolate with its own engine**: nothing from `main()` has run there, so call `Firebase.initializeApp` before any other Firebase call and do not rely on globals or providers. The plugin wraps the handler in a try/catch and only **prints** the error, so a failure looks like the handler never ran. Also check the device: a force-stopped app gets no messages until it is reopened, and work longer than about 30 seconds may be killed.

code

dart · 33 lines
dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';

import 'firebase_options.dart';

Future<void> markOrderShipped(String orderId) async {
  // Write to a local database or call your API.
}

Future<void> reportBackgroundFailure(Object error, StackTrace stack) async {
  // Send to your own error reporting.
}

@pragma('vm:entry-point')
Future<void> onBackgroundPush(RemoteMessage message) async {
  try {
    // Fresh isolate on Android: nothing from main() has run here.
    await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
    final orderId = message.data['orderId'];
    if (orderId is String) await markOrderShipped(orderId);
  } catch (error, stack) {
    // The plugin would only print this; make it visible.
    await reportBackgroundFailure(error, stack);
  }
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseMessaging.onBackgroundMessage(onBackgroundPush);
  runApp(const MaterialApp(home: Scaffold()));
}

go deeper

for a junior

Remember the three rules for the handler: a named top-level function, the @pragma('vm:entry-point') annotation, and registration in main() before runApp.

for a middle

Explain why release AOT tree shaking removes an unannotated handler, and why on Android it runs in a fresh isolate that must call Firebase.initializeApp itself.

for a senior

Show a diagnosis order: annotation, registration shape, swallowed errors, force-stopped apps and time limits, and how you test background delivery on a release build with real device logs.

for a principal

Decide what work belongs in a background handler at all, and which is better deferred to the next launch or a server, given OS kill limits, no UI and a separate isolate.

## Why debug and release differ A Flutter **debug** build runs Dart on a JIT and keeps every function. A **release** build is compiled ahead of time (AOT), and the compiler **tree-shakes** code that no Dart code reaches. A **firebase_messaging** background handler is never called from your Dart code. On Android, the plugin hands a callback handle to native code, and native code calls the handler back when a message arrives. To the compiler it looks unused. `@pragma('vm:entry-point')` tells the compiler that something outside Dart will call this function, so it is kept. The FlutterFire guide requires the annotation from Flutter 3.3.0 onward. Flutter's add-to-app docs require the same pragma on any non-`main` entry point, for the same reason. ## The registration contract `FirebaseMessaging.onBackgroundMessage(handler)` takes a `Future<void> Function(RemoteMessage)`. The handler must be: - **a named top-level function**, not a closure and not an instance method. On Android the plugin resolves it with `PluginUtilities.getCallbackHandle`, which has no handle to give for a closure, so registration fails. The docs say an `ArgumentError`; the current source fails on a null check. - **registered in `main()`**, before `runApp`, so it is in place whenever the process starts for a message. - **annotated** with `@pragma('vm:entry-point')`. ## Life inside the background isolate (Android) On Android the plugin starts a **separate isolate in its own `FlutterEngine`**, launched by a background service when a message arrives. On iOS and macOS the plugin does not spawn a separate isolate. Consequences on Android: 1. **Nothing from `main()` ran.** Globals hold their initial values, Riverpod or BLoC containers do not exist, and `Firebase.initializeApp` has not been called. Call `await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform)` before using Firestore or any other Firebase service. 2. **No UI.** You cannot call `setState`, navigate or read `BuildContext`. You can do I/O, HTTP requests, local-database writes and calls to other plugins. 3. **Errors vanish.** The dispatcher wraps your handler in a `try`/`catch` and prints `FlutterFire Messaging: An error occurred in your background messaging handler:` followed by the error. A throw on the first line, such as a Firebase service used before initialisation, looks exactly like the handler never running. Catch errors yourself and report them somewhere you will see. 4. **Be quick.** The guide warns that the OS may kill the process if work runs longer than about 30 seconds. 5. **Command-line engine flags do not reach it.** Flags passed to `flutter run` apply to the activity's engine. Since Flutter 3.44 you can set flags for the background engine as `io.flutter.embedding.android.*` metadata in `AndroidManifest.xml`. Most of these debug flags are ignored in release builds. ## Device and delivery conditions Some causes are not in your code: - The app must have been **opened at least once**, so it could register with FCM. - On Android, an app **force-stopped from system settings** receives nothing until the user opens it again. - Whether a given message reaches the handler also depends on how it was sent: notification or data payload, and its priority. Those rules are FCM's delivery semantics, covered in the Firebase messaging topic. ## A diagnosis order | Symptom | Likely cause | Check | |---|---|---| | Works in debug, silent in release | handler tree-shaken | `@pragma('vm:entry-point')` present | | Crash or error at startup | closure or method passed | named top-level function | | Handler starts, then nothing | throw before your first log | `Firebase.initializeApp` inside, own try/catch | | Works, then stops for a user | force-stopped app | reopen the app, then retest | | Partial work | killed mid-task | keep it short, defer heavy work | To test in release, run `flutter run --release`, background the app, send a data message and watch the device log for your own output or the plugin's error line.

  • The handler reads a userId global that main() sets after sign-in. Why is it null in the background on Android?
    On Android the handler runs in a separate isolate started by a background service, and isolates share no memory. `main()` never ran there, so the global holds its initial value. Persist what the handler needs, for example in local storage, and read it inside the handler.
  • Does the same handler run in a separate isolate on iOS?
    No. The plugin's docs say only Android spawns a separate isolate, and the Dart side registers the background isolate only on Android. The same rules still apply, though: a named, annotated top-level function with no UI work.

The background handler is a night-shift worker let in through a side door by the building's alarm system. In a release build, the cleaners remove every desk nobody signed out, and @pragma('vm:entry-point') is the name tag that says someone uses this desk at night. The worker arrives to an empty building: none of the day shift's notes (main's globals) are there, so they must switch the lights on themselves (Firebase.initializeApp). If they trip, they only mutter to themselves (a print), so nobody hears it unless you give them a radio (your own error reporting).

saying these in an interview costs you the question

  • An anonymous closure passed to onBackgroundMessage works fine.
  • @pragma('vm:entry-point') only matters for hot reload in debug.
  • The background handler shares globals and providers with the UI isolate.
  • An exception in the handler crashes the app, so you would notice it.
  • Firebase is already initialised inside the handler because main() did it.