← Back to Blog

Localize Flutter Push Notifications in the FCM Isolate

flutteri18npush-notificationsfirebasel10n

Localize Flutter Push Notifications in the FCM Isolate

Your app has an in-app language switcher. The user picked French. Every screen is French. Then a push arrives at 9pm while the app is terminated — and it's in English.

The reflex fix is to reach for the global navigatorKey and call AppLocalizations.of(navigatorKey.currentContext!). In a background handler that reflex is not just wrong, it's guaranteed to crash or silently no-op. On Android, FirebaseMessaging.onBackgroundMessage spawns a separate Dart isolate. There is no widget tree, no Localizations widget, no MaterialApp, and no warm memory from your running app. Your navigatorKey.currentContext is null because the isolate that owns that key doesn't exist.

There are exactly two honest ways to fix this. Here's both, plus the gotchas that make the first one fail silently in release builds.

What actually runs when the push lands

On Android, FCM spawns a fresh isolate and runs your top-level handler. On iOS and macOS, no separate isolate is spawned — but if the user force-quit the app, iOS generally won't run your Dart code at all. That asymmetry is the single most important input to the decision below.

Also note: a message containing a notification block is intercepted and drawn by the Firebase SDK before your Dart ever runs. If you want to control the strings, you must send a data-only message.

Path 1: Locale-agnostic data payload, resolved in Dart

The server sends a key, not a sentence. The device already knows which language the user chose.

{
  "message": {
    "token": "<device-token>",
    "data": {
      "loc_key": "new_message",
      "sender": "Amélie",
      "count": "3",
      "route": "/chats/42"
    },
    "android": { "priority": "HIGH" },
    "apns": {
      "headers": { "apns-priority": "5", "apns-push-type": "background" },
      "payload": { "aps": { "content-available": 1 } }
    }
  }
}

Data-only messages are treated as low priority by default and can be throttled or dropped when the app is backgrounded or terminated. "priority": "HIGH" on Android is not optional here. Note every FCM data value is a string — count arrives as "3", never 3.

The ARB side

{
  "@@locale": "en",
  "pushNewMessageTitle": "New message",
  "pushNewMessageBody": "{sender} sent you {count, plural, =1{a message} other{{count} messages}}",
  "@pushNewMessageBody": {
    "placeholders": {
      "sender": { "type": "String" },
      "count": { "type": "int" }
    }
  },
  "channelMessagesName": "Messages",
  "channelMessagesDescription": "Direct messages from other people"
}

flutter gen-l10n turns that into t.pushNewMessageBody(sender, count) — positional, in the order the placeholders are declared.

The handler

// lib/messaging/background_handler.dart
import 'dart:ui';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../l10n/app_localizations.dart'; // generated by `flutter gen-l10n`

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  WidgetsFlutterBinding.ensureInitialized();
  DartPluginRegistrant.ensureInitialized();
  await Firebase.initializeApp();

  final data = message.data;
  final key = data['loc_key'];
  if (key == null) return;

  // Re-read the user's chosen language from disk. Not the device locale.
  final code = await SharedPreferencesAsync().getString('app_locale') ?? 'en';

  var locale = Locale(code);
  if (!AppLocalizations.delegate.isSupported(locale)) {
    locale = const Locale('en');
  }
  final t = await AppLocalizations.delegate.load(locale);

  final count = int.tryParse(data['count'] ?? '') ?? 1;
  final (String, String)? strings = switch (key) {
    'new_message' => (
        t.pushNewMessageTitle,
        t.pushNewMessageBody(data['sender'] ?? '', count),
      ),
    _ => null,
  };
  if (strings == null) return;

  final plugin = FlutterLocalNotificationsPlugin();
  await plugin.initialize(
    settings: const InitializationSettings(
      android: AndroidInitializationSettings('@mipmap/ic_launcher'),
    ),
  );

  await plugin.show(
    id: message.messageId?.hashCode ?? 0,
    title: strings.$1,
    body: strings.$2,
    notificationDetails: NotificationDetails(
      android: AndroidNotificationDetails(
        'messages',
        t.channelMessagesName,
        channelDescription: t.channelMessagesDescription,
        channelAction: AndroidNotificationChannelAction.update,
        importance: Importance.max,
        priority: Priority.high,
      ),
    ),
    payload: data['route'],
  );
}

Register it in main() — the registration call itself must run on the main isolate:

FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);

The four gotchas that make this fail silently

1. @pragma('vm:entry-point') is mandatory. Since Flutter 3.3, without it the AOT compiler tree-shakes your handler away. It works perfectly in debug and does nothing in release — the worst possible failure mode.

2. It must be a top-level function. Not a static method, not a closure, not an anonymous function.

3. WidgetsFlutterBinding.ensureInitialized() before any plugin call. A fresh isolate has no binding, so the method channels shared_preferences and flutter_local_notifications ride on don't exist yet. If you still see MissingPluginException(No implementation found for method getAll...), that's the plugin registrant — DartPluginRegistrant.ensureInitialized() from dart:ui is the documented fix for background isolates.

4. Use SharedPreferencesAsync, not the cached API. The legacy SharedPreferences.getInstance() keeps a per-isolate singleton cache, and the package explicitly documents that each isolate gets its own cache — so a value written by your main isolate can read stale here. SharedPreferencesAsync keeps no local cache and always reads the native store. If you're stuck on the legacy API, call await prefs.reload() first.

One bonus: AppLocalizations.delegate.load() is cheap. gen_l10n emits plain Dart classes and returns a SynchronousFuture, so there's no asset I/O and nothing to await meaningfully. It also throws for an unsupported locale — hence the isSupported guard, which matters because a code that was valid last release may not be after a locale gets dropped.

The channel name is user-visible too — and you can update it

Android shows your notification channel's name in the shade and in system settings. If you localize the title and body but leave the channel named "Messages" for a French user, the job is half done.

This is explicitly supported. Android's createNotificationChannel() documentation states it "can also be used to restore a deleted channel and to update an existing channel's name, description, group, and/or importance," and that "the name and description should only be changed if the locale changes or in response to the user renaming this channel" — the docs even use a locale change as the worked example. So there is no delete-and-recreate, and the user's per-channel preferences survive intact.

The catch is at the plugin layer, not the platform layer: flutter_local_notifications defaults channelAction to createIfNotExists, which no-ops when the channel already exists. That's why people conclude channel names can't be updated. Pass AndroidNotificationChannelAction.update (as above) and the localized name goes through. Importance won't move — Android only ever lowers it, and only if the user hasn't touched the channel — but name and description will.

Path 2: Localize server-side from a stored user locale

Store the user's chosen language next to their FCM token, and re-sync it every time they flip the switcher. Then send finished strings:

{
  "message": {
    "token": "<device-token>",
    "notification": {
      "title": "Nouveau message",
      "body": "Amélie vous a envoyé 3 messages"
    },
    "android": { "priority": "HIGH" }
  }
}

No isolate, no Dart, no vm:entry-point. The OS draws it. This works when the app is force-quit on iOS, which Path 1 fundamentally cannot.

The cost: your translations now live in two places. Keep the push strings in your ARB files as the single source of truth and export them to the backend, rather than maintaining a parallel set of server-side strings that drift.

Which one to pick

Path 1 — data payload + Dart Path 2 — server-side
iOS, app force-quit Won't run Works
Strings live in your ARB files Yes, directly Only if you export them
Ship a new language App update Backend deploy
Deep per-user formatting (currency, relative time) Full intl on device Must replicate server-side
Server complexity Sends keys only Needs locale per token + translations
Fails if user changes language offline No — reads local disk Until the locale sync lands

Most teams shipping to both platforms end up on Path 2 for transactional pushes, with Path 1 for rich, locally-computed content on Android. Picking one and being consistent beats a half-migrated hybrid.

Why the device locale is the wrong fallback

FCM has title_loc_key / body_loc_key and APNs has title-loc-key / loc-key. They look like exactly the feature you want: send a key, the OS resolves it from your app's string resources.

They resolve against the device locale. If your app has an in-app language switcher, the device locale is precisely the thing your user overrode. A user with a German phone who set your app to Turkish gets German pushes, forever, with no way to fix it. Same trap applies to reading PlatformDispatcher.instance.locale inside the isolate — it reports the OS setting, not the user's choice.

The persisted app_locale key is the only source of truth. Device locale is a reasonable default at first launch, and nothing after that.

Keep the push keys honest

Push strings are the easiest keys to let rot: they never render during development, so a missing French translation shows as an English notification in production rather than a red screen in your simulator. They're also plural-heavy — "3 new messages" is a plural in every language, and Arabic, Polish and Russian need categories that English doesn't have. A dropped few or many in a push body is a real bug your test devices will never surface.

That's the gap FlutterLocalisation is built for: an ARB editor for your app_<locale>.arb files so translators aren't editing raw JSON, translation management across every locale you ship, and ICU plural-syntax validation that flags a locale missing a plural category its language actually requires. More Flutter i18n walkthroughs live on the blog, and the pricing page has the full breakdown.

Try FlutterLocalisation free and get your push keys validated before they ship in the wrong language.