Localize Flutter Notifications Without a BuildContext
Your @pragma('vm:entry-point') background handler fires, you reach for AppLocalizations.of(context), and there is no context to hand it. So you ship the English string, and every Arabic and Japanese user gets a notification in a language they did not pick.
The fix is not to smuggle a BuildContext into the isolate. It is to load the localizations directly.
Why the usual advice fails here
AppLocalizations.of(context) is an InheritedWidget lookup. It needs a widget tree above it, and a background isolate has none. Three things people try, and why each one dies:
navigatorKey.currentContext— null. The FCM background handler runs in a separate isolate with its own engine; yourGlobalKeylives in the UI isolate and is not shared.- Passing
contextinto the handler — the handler must be a top-level function, and nothing crosses the isolate boundary except what you persist to disk. - A global
AppLocalizationssingleton set inmain()—main()never ran in this isolate. Your global is at its default value.
AppLocalizations.delegate.load() is the whole trick
The generated AppLocalizations.delegate is an ordinary LocalizationsDelegate<AppLocalizations>. Its load(Locale) just calls the generated lookupAppLocalizations(locale) and wraps the result in a SynchronousFuture — no channels, no I/O, no widget tree. Nothing about it requires a context; the Localizations widget is only what normally calls it for you. (If you want the mechanics, see Flutter localization delegates explained.)
// This works in any isolate, at any time.
final AppLocalizations l10n = await AppLocalizations.delegate.load(const Locale('ar'));
print(l10n.newMessageTitle);
So the real work is answering one question inside the isolate: which locale?
Step 1: store the user's locale where the isolate can read it
Whenever the user changes language in your settings screen (or your app first resolves a device locale), write the tag to disk. Use SharedPreferencesAsync, not the legacy SharedPreferences — the legacy API keeps a per-isolate in-memory cache, and the shared_preferences docs call this out explicitly for multi-isolate use: each isolate has its own singleton and cache, so a stale read is possible unless you reload(). SharedPreferencesAsync (shared_preferences 2.3+, current 2.5.x) skips the cache and always hits the platform store.
// In the UI isolate, when the user picks a language.
await SharedPreferencesAsync().setString('user_locale', locale.toLanguageTag()); // "ar-EG"
Step 2: resolve it against supportedLocales, do not compare it
This is where most implementations quietly fall back to English. The saved tag is ar-EG, and the matching code is some variant of:
// Broken: ar-EG is not in the list, so every Egyptian user gets English.
final locale = AppLocalizations.supportedLocales.contains(saved)
? saved
: const Locale('en');
Flutter already ships the algorithm MaterialApp uses for exactly this: basicLocaleListResolution, exported from package:flutter/widgets.dart. It walks a preferred list and matches in priority order — full language+script+country, then language+script, then language+country, then language alone, then country alone — and returns the first entry of supportedLocales only when nothing matches at all. ar-EG lands on ar. pt-PT lands on pt unless you also ship pt-BR-style variants.
// lib/l10n/l10n_isolate.dart
import 'dart:ui';
import 'package:flutter/widgets.dart' show Locale, basicLocaleListResolution;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Loads AppLocalizations in any isolate. No BuildContext required.
Future<AppLocalizations> loadIsolateLocalizations() async {
final locale = await resolveUserLocale();
return AppLocalizations.delegate.load(locale);
}
Future<Locale> resolveUserLocale() async {
final saved = await SharedPreferencesAsync().getString('user_locale');
// The saved tag comes first. PlatformDispatcher.locales is a backup only:
// in an engine-spawned background isolate it can come back empty or as 'und'.
final preferred = <Locale>[
if (saved != null) _parseTag(saved),
...PlatformDispatcher.instance.locales
.where((l) => l.languageCode != 'und' && l.languageCode.isNotEmpty),
];
if (preferred.isEmpty) return AppLocalizations.supportedLocales.first;
return basicLocaleListResolution(preferred, AppLocalizations.supportedLocales);
}
Locale _parseTag(String tag) {
final parts = tag.replaceAll('-', '_').split('_');
return switch (parts.length) {
1 => Locale(parts[0]),
2 => Locale(parts[0], parts[1]),
_ => Locale.fromSubtags(
languageCode: parts[0], scriptCode: parts[1], countryCode: parts[2]),
};
}
Put AppLocalizations.supportedLocales in your MaterialApp too, so the two lists can never drift apart.
Step 3: the full background handler
This is against flutter_local_notifications 22.x, where initialize() and show() take named parameters (they were positional up to 19.x — this is the single most common copy-paste break right now).
// lib/notifications/background.dart
import 'dart:ui';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../l10n/l10n_isolate.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
DartPluginRegistrant.ensureInitialized(); // so shared_preferences resolves here
final l10n = await loadIsolateLocalizations();
final plugin = FlutterLocalNotificationsPlugin();
await plugin.initialize(
settings: const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
// Never prompt for permission from a background isolate.
iOS: DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
),
),
);
// Channel name and description are user-visible strings: translate them too.
final channel = AndroidNotificationChannel(
'reminders',
l10n.channelRemindersName,
description: l10n.channelRemindersDescription,
importance: Importance.high,
);
await plugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
final data = message.data;
final body = switch (data['type']) {
'streak' => l10n.streakBody(int.tryParse(data['count'] ?? '') ?? 0),
'chat' => l10n.newMessageFrom(data['sender'] ?? ''),
_ => l10n.genericUpdateBody,
};
await plugin.show(
id: message.messageId.hashCode,
title: l10n.appName,
body: body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
channel.id,
channel.name,
channelDescription: channel.description,
importance: Importance.high,
priority: Priority.high,
),
iOS: const DarwinNotificationDetails(),
),
payload: data['deeplink'],
);
}
Register it the usual way, before runApp:
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
The handler must be top-level, must not be an anonymous function, and must carry @pragma('vm:entry-point') or tree shaking removes it from release builds. Keep it under ~30 seconds; the OS may kill it after that.
Android channel names: re-create with the same ID
A channel created once keeps its name forever unless you say otherwise — which is why so many apps show a translated notification sitting under an English channel heading in system settings. Android's createNotificationChannel can update an existing channel's name, description and group; the docs say the name and description "should only be changed if the locale changes or in response to the user renaming this channel." Other fields (sound, vibration) are ignored once the channel exists.
So calling createNotificationChannel with the same id and freshly localized strings is the correct move, not a hack. Do it in the background handler as above, and again on app start after a locale change.
Send data-only messages, or none of this runs
If your FCM payload contains a notification block, the OS displays that text itself while the app is backgrounded and your Dart code never gets to translate anything. Send data-only messages and build the notification yourself:
{
"message": {
"token": "<device-token>",
"data": { "type": "streak", "count": "7", "deeplink": "/streak" },
"android": { "priority": "high" },
"apns": {
"headers": { "apns-priority": "5", "apns-push-type": "background" },
"payload": { "aps": { "content-available": 1 } }
}
}
}
FCM does have title_loc_key / body_loc_key, but those resolve against Android strings.xml and iOS Localizable.strings — not your ARB files. You would be maintaining a second, duplicated translation set. Data-only plus AppLocalizations keeps one source of truth. On iOS, note that background data-only delivery is throttled by the system, so anything time-critical belongs in a scheduled local notification instead.
Dates and numbers need one extra line
delegate.load() gives you your ARB strings, not intl's date symbols. If the notification body formats a date or a currency, set the locale for intl in the isolate as well:
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
initializeDateFormatting();
Intl.defaultLocale = locale.toLanguageTag();
The same handler shape works for zonedSchedule in scheduled local notifications: resolve the locale, load the delegate, then build the strings. Since the schedule is written at queue time, re-schedule pending notifications when the user changes language, otherwise tomorrow's reminder arrives in yesterday's language.
Then make sure the strings themselves are right
Notification bodies are where plural bugs surface hardest: streakBody with a count needs one/other in English, but zero, one, two, few, many, other in Arabic. A missing few shows a raw fallback to the exact users you just went to this trouble for.
FlutterLocalisation's ARB editor edits your app_<locale>.arb files in a UI instead of raw JSON, and its ICU plural validation flags any locale missing a plural category that language actually needs. See the pricing page for tiers, or browse more Flutter i18n guides — including the broader walkthrough of Flutter push notification localization.
Try FlutterLocalisation free and stop shipping English to your Arabic users.