Push Notifications, Background Work & Deep Links
FCM, local notifications, WorkManager, deep links, and app lifecycle
Open interactive version (quiz + challenge)Real-world analogy
Think of push notifications as a doorbell system for your app. FCM is the postal service that delivers the ring signal, local notifications are your doorbell hardware, WorkManager is a reliable butler who handles tasks even when you're asleep, and deep links are the house address that routes visitors straight to the right room — not just the front door.
What is it?
Push notifications let servers proactively alert users. Background work lets apps run tasks without being in the foreground. Deep links route users from external sources (email, web, other apps) directly into specific app screens. Together these form the backbone of engagement and reliability for production Flutter apps.
Real-world relevance
In a SaaS collaboration app: FCM delivers 'New message in #general' notifications. Background message handler silently updates the local SQLite chat cache so the inbox is fresh when the user opens the app. WorkManager runs a nightly sync job. Deep links from email notifications open directly to the relevant workspace and channel.
Key points
- FCM Setup — Firebase Cloud Messaging requires google-services.json (Android) and GoogleService-Info.plist (iOS). Initialize with Firebase.initializeApp() and request notification permissions via firebase_messaging before accessing tokens.
- FCM Token — FirebaseMessaging.instance.getToken() returns a device-unique registration token. Store this on your server to target specific devices. Tokens can rotate — listen to onTokenRefresh to update your server.
- Message Handlers — FCM provides three entry points: onMessage (foreground), onMessageOpenedApp (background tap), and getInitialMessage (terminated state tap). Each requires different handling logic.
- Background Message Handler — FirebaseMessaging.onBackgroundMessage(handler) runs in a separate Dart isolate on Android. The handler must be a top-level function (not a class method) and cannot access UI or most Flutter state.
- Local Notifications — flutter_local_notifications displays scheduled or immediate notifications without FCM. Useful for alarms, reminders, and offline notifications. Requires channel setup on Android 8+ (Oreo+).
- WorkManager — workmanager package wraps Android WorkManager and iOS BGTaskScheduler. Use for deferred background tasks (sync, cleanup). Tasks run even after app is killed, constrained by battery and network conditions.
- Deep Links — app_links package (successor to uni_links) handles both App Links (Android) and Universal Links (iOS). Requires intent-filter in AndroidManifest.xml and associated domains in Apple entitlements.
- AppLifecycleState — WidgetsBindingObserver.didChangeAppLifecycleState fires: resumed (foreground), inactive (transition), paused (background), detached (terminated). Use to pause/resume resources like timers or camera streams.
- Foreground Notification Display — On iOS, FCM foreground messages are silent by default. Use FirebaseMessaging.instance.setForegroundNotificationPresentationOptions to show banners/badges/sounds while app is open.
- Notification Channels — Android 8+ requires notification channels with importance levels. Create channels at app startup. Users can disable individual channels in system settings — your app must handle missing permission gracefully.
- Testing Push — Use Firebase Console, FCM REST API, or the FlutterFire CLI to send test messages. Always test all three app states (foreground, background, terminated) as behavior differs significantly.
Code example
// --- FCM SETUP (main.dart) ---
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// Top-level function — runs in separate isolate
await _updateLocalCache(message.data);
}
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
runApp(const MyApp());
}
// --- PERMISSION & TOKEN ---
class NotificationService {
static Future<String?> init() async {
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true, badge: true, sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
await messaging.setForegroundNotificationPresentationOptions(
alert: true, badge: true, sound: true,
);
}
return messaging.getToken();
}
static void listen(BuildContext context) {
// Foreground messages
FirebaseMessaging.onMessage.listen((msg) {
_showLocalNotification(msg);
});
// Background tap
FirebaseMessaging.onMessageOpenedApp.listen((msg) {
_navigate(context, msg.data);
});
}
}
// --- DEEP LINKS (app_links) ---
class DeepLinkService {
final _appLinks = AppLinks();
void init(BuildContext context) {
// App was terminated — get initial link
_appLinks.getInitialLink().then((uri) {
if (uri != null) _handleUri(context, uri);
});
// App running — stream incoming links
_appLinks.uriLinkStream.listen((uri) => _handleUri(context, uri));
}
void _handleUri(BuildContext context, Uri uri) {
// e.g. myapp://workspace/123/channel/456
if (uri.pathSegments.length >= 2 && uri.pathSegments[0] == 'workspace') {
Navigator.pushNamed(context, '/workspace',
arguments: {'id': uri.pathSegments[1]});
}
}
}
// --- WORKMANAGER BACKGROUND TASK ---
void callbackDispatcher() {
Workmanager().executeTask((taskName, inputData) async {
switch (taskName) {
case 'syncTask':
await SyncService.runSync();
return Future.value(true);
default:
return Future.value(false);
}
});
}
// Register periodic task
await Workmanager().initialize(callbackDispatcher);
await Workmanager().registerPeriodicTask(
'syncTask', 'syncTask',
frequency: const Duration(hours: 1),
constraints: Constraints(networkType: NetworkType.connected),
);
// --- APP LIFECYCLE ---
class _MyWidgetState extends State<MyWidget> with WidgetsBindingObserver {
@override void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) _pauseVideoStream();
if (state == AppLifecycleState.resumed) _resumeVideoStream();
}
}Line-by-line walkthrough
- 1. _firebaseMessagingBackgroundHandler is top-level — required because it runs in a new Dart isolate
- 2. Firebase.initializeApp() must be called again inside the background handler — the isolate starts fresh
- 3. setForegroundNotificationPresentationOptions ensures FCM messages show banners while the app is open on iOS
- 4. FirebaseMessaging.onMessage handles foreground messages — typically shown via flutter_local_notifications
- 5. FirebaseMessaging.onMessageOpenedApp handles the background-tap case — navigate to the relevant screen
- 6. appLinks.getInitialLink() retrieves a deep link that launched the app from terminated state — checked once at startup
- 7. appLinks.uriLinkStream handles links arriving while app is running — continuous stream
- 8. Workmanager constraints: NetworkType.connected ensures sync only runs with internet — prevents failed attempts
Spot the bug
// Background message handler
class NotificationHandler {
final DatabaseService _db;
NotificationHandler(this._db);
Future<void> handleBackground(RemoteMessage message) async {
await _db.updateCache(message.data);
}
}
// In main.dart
final handler = NotificationHandler(DatabaseService());
FirebaseMessaging.onBackgroundMessage(handler.handleBackground);Need a hint?
This will crash at runtime. Look at the type of function onBackgroundMessage expects.
Show answer
Bug: onBackgroundMessage requires a top-level function, not an instance method. handler.handleBackground captures 'this' (the NotificationHandler instance), making it a closure that cannot be passed across isolate boundaries. Fix: Make the handler a top-level function. If you need database access, initialize DatabaseService inside the handler itself: Future<void> _bgHandler(RemoteMessage msg) async { await Firebase.initializeApp(); final db = DatabaseService(); await db.updateCache(msg.data); }
Explain like I'm 5
Imagine your app is a person who sometimes sleeps (background) or is completely gone (terminated). Push notifications are like alarm clocks that can wake them up with a message. Deep links are like a GPS address that takes you straight to a specific room in a building instead of just the front door. WorkManager is a reliable assistant that does chores for you even while you sleep — and won't forget even if you restart the house.
Fun fact
Android's WorkManager was built to replace four separate background task APIs (AsyncTask, IntentService, JobScheduler, Firebase JobDispatcher) that developers were confused about. Flutter's workmanager package wraps WorkManager on Android and BGTaskScheduler on iOS — two completely different systems — behind one Dart API.
Hands-on challenge
Design the notification architecture for a fintech claims app: (1) FCM message arrives with claim status update — describe the full handling path for all three app states. (2) How do you ensure the user lands on the correct claim detail screen when tapping the notification? (3) What WorkManager task would you schedule and what constraints would you apply?
More resources
- FlutterFire — Cloud Messaging (FlutterFire Docs)
- workmanager package (pub.dev)
- app_links package (pub.dev)
- flutter_local_notifications (pub.dev)
- AppLifecycleState API (Flutter Docs)