Lesson 41 of 77 advanced

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

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. 1. _firebaseMessagingBackgroundHandler is top-level — required because it runs in a new Dart isolate
  2. 2. Firebase.initializeApp() must be called again inside the background handler — the isolate starts fresh
  3. 3. setForegroundNotificationPresentationOptions ensures FCM messages show banners while the app is open on iOS
  4. 4. FirebaseMessaging.onMessage handles foreground messages — typically shown via flutter_local_notifications
  5. 5. FirebaseMessaging.onMessageOpenedApp handles the background-tap case — navigate to the relevant screen
  6. 6. appLinks.getInitialLink() retrieves a deep link that launched the app from terminated state — checked once at startup
  7. 7. appLinks.uriLinkStream handles links arriving while app is running — continuous stream
  8. 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

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery