Serverless Scheduled Push Notifications with Firebase and Cloud Tasks

Serverless infrastructure for timer notifications.

05 Jan 2023

Introduction

As a developer constantly experimenting with new ideas, serverless computing has become my go-to solution for quickly spinning up projects without the burden of server costs or scaling concerns. It allows me to explore freely, testing and iterating on countless small projects with minimal commitment.

At its core, serverless computing is about leveraging a suite of managed services provided by cloud providers. Instead of setting up and maintaining your own servers, you can take advantage of services that abstract away the infrastructure, allowing you to focus on writing code and building features. Cloud providers like Google Cloud Platform, AWS, and Azure offer managed services for everything from databases and messaging to machine learning and security. By combining these services, you can build complex applications with less operational overhead and more agility.

In this article, I’ll guide you through how I utilized Google Cloud Platform (GCP) and its serverless infrastructure to build a push notification system for Wave, a simple pomodoro tracker I built.

Implementation

When building Wave, I needed an affordable solution to notify users when their timers complete. Since I was already using Firebase for Cloud Firestore — a real-time NoSQL cloud database hosted on GCP — sticking with GCP for this task was a natural choice.

Sending notifications

GCP offers Firebase Cloud Messaging (FCM), a simple, serverless messaging service perfect for this purpose. FCM’s benefits include:

  1. seamless integration with Apple Push Notifications for iOS and support for multiple platforms like Android, Web, C++, and more;
  2. flexibility in sending notifications to individual devices, groups, or all devices subscribed to a topic;
  3. elimination of the cost and complexity associated with running a dedicated server for infrequent notifications.

Creating the interface

The first step is to create a cloud function to trigger notifications. This allows us to execute server-like code without needing a dedicated server. Given that we're already on GCP, we can define our cloud function using GCP Cloud Functions. Cloud Functions are cost-effective, with minimal compute costs and generous free tiers.

💡

As of 2023, functions are free for the first 2 million invocations per month and only US$0.40 per million requests after that.

import * as functions from 'firebase-functions';

export const sendNotification = functions.https.onRequest(async (req, res) => {
  const payload = req.body;
  const { userId } = payload;
  // ...
});
💡

To learn how to setup a Cloud Functions project locally, refer to this guide.

Multi-device support

Wave supports multi-device sync, meaning a single notification must be sent to multiple devices. To achieve this, we associate every device with a user.

We store an array of device tokens for each user ID in Firestore. When a user logs in on a new device, Wave reads the device token and adds it to the /devices/{userId} document. If the user is not logged in, they receive an anonymous user ID instead.

/// On a client device.
import messaging from '@react-native-firebase/messaging';

// This hook is called in the main app container.
export function useRegisterDeviceToken() {
  // A custom hook to get the current user from context.
  const user = useUser();
  const token = useRef<string>();

  // Save the initial token for a user.
  useEffect(function saveInitialToken() {
    async function saveToken() {
      if (user == null) return;
      token.current = await messaging().getToken();
      await updateDeviceTokenMemory(user.uid, token.current);
    }
    saveToken();
  }, [user]);

  // Sync the token when it refreshes.
  useEffect(function syncToken() {
    return messaging().onTokenRefresh(async (newToken) => {
      if (user == null) return;
      await updateDeviceTokenMemory(user.uid, newToken, token.current);
      token.current = newToken;
    });
  }, [user]);
}

When a notification needs to be sent, the cloud function retrieves the list of associated device tokens for a given user ID from Firestore and uses those tokens for a multicast notification.

/// In the serverless context.
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

export const sendNotification = functions.https.onRequest(async (req, res) => {
  // ...
  const ref = admin
    .firestore()
    .doc(`devices/${payload.userId}`);
  const snapshot = await ref.get();
  const tokens = snapshot.data()?.tokens ?? [];
  admin.messaging().sendMulticast({
    tokens,
    notification: {
      title: 'Focus over!',
      body: 'Take a five minute break',
    },
  });
  res.sendStatus(200);
});

Authentication

The last step is to add an authentication token to the request. This can either be a static secret, a dynamic secret stored in the database, or a checksum generated from the user ID and notification timestamp.

With the notification cloud function in place, let’s explore how to schedule notifications.

Scheduling notifications

Timer design

Wave employs a bandwidth-efficient timer design, updating the database only when a user starts or pauses a timer:

  • when the timer is active, the time remaining is derived from the current time and timer start time,
  • when the timer is paused, the time remaining is derived from the timer pause time and timer start time.
  • when a timer is unpaused, we simply move the start time forward by the appropriate amount to simulate a later start time.

Therefore, we only need to schedule notifications when there are changes to a user’s timer data. We calculate the expected end time whenever the timer is started and queue a task for that time. If the timer is paused, we delete the associated task.

To schedule these tasks, we use GCP’s Cloud Tasks, a managed serverless service that allows asynchronous task execution and scheduling.

Setting up Cloud Tasks

Cloud Tasks requires billing to be enabled, but it’s very affordable.

💡

As of 2023, we get 1 million free requests per month and only pay US$0.40 per million requests after that.

Once the Cloud Tasks API is enabled, create a queue for notifications. Install the gcloud CLI, configure it for your project, and run:

gcloud tasks queues create {queue_name}

Creating a task

We’ll create another cloud function to schedule tasks using the @google-cloud/tasks npm package. Unlike the previous HTTP-triggered function, this one listens for changes in our Firestore collection.

export const onTimerUpdate = functions.firestore
  .document('/timers/{userId}')
  .onWrite(async (change, context) => {
    // ...
    await scheduleTimerNotification({userId, startMs});
  });

To schedule the notification, create a task on the previously created queue:

import { CloudTasksClient } from '@google-cloud/tasks';

const SEND_NOTIFICATION_URL =
  `https://${LOCATION}-${PROJECT_ID}.cloudfunctions.net/sendNotification`;

async function scheduleTimerNotification({
  startMs,
  userId,
}: ScheduleNotificationProps) {
  const tasksClient = new CloudTasksClient();
  const queuePath = tasksClient.queuePath(PROJECT_ID, LOCATION, QUEUE_NAME);

  const endSeconds = 25_MINS_SEC + Math.floor(startMs / 1000);
  const payload = {
    userId,
    token: generateToken(userId, startMs),
  };

  const [task] = await tasksClient.createTask({
    parent: queuePath,
    task: {
      scheduleTime: { seconds: endSeconds },
      httpRequest: {
        httpMethod: 'POST',
        url: SEND_NOTIFICATION_URL,
        body: Buffer.from(JSON.stringify(payload)).toString('base64'),
        headers: { 'Content-Type': 'application/json' },
      },
    },
  });
}

When the task executes, it sends a HTTP POST request to the sendNotification cloud function, triggering the notification for the specified user.

Deleting a task

To delete a task, pass the task’s name to tasksClient.deleteTask. Cloud Tasks allows for named tasks, so we may be tempted to use the user ID as a task name. However, tasks with the same name cannot be created within 30 minutes of the previous task with that name. Therefore, using user IDs as task names would effectively limit notifications to one notification per user every 30 minutes.

To avoid this, we create tasks with random names and store the name in our database.

async function scheduleTimerNotification({
  startMs,
  userId,
}: ScheduleNotificationProps) {
  const [task] = await tasksClient.createTask(...);

  if (task.name != null) {
    await admin
      .firestore()
      .doc(`timer-notifications/${userId}`)
      .set({ name: task.name });
  }
}

When a notification needs to be canceled, we look up the task name in the database and delete the task.

async function cancelTimerNotification(userId: string) {
  const ref = admin
    .firestore()
    .doc(`timer-notifications/${userId}`);
  const snapshot = await ref.get();
  const data = snapshot.data();
  await tasksClient.deleteTask({ name: data.name });
}

Potential vendor lock-in risks

While serverless computing offers significant benefits, it’s important to consider the potential risks of vendor lock-in, especially when your project relies heavily on a specific cloud provider's managed services. For example, in building Wave, I utilized GCP Firebase for Cloud Firestore and Firebase Cloud Messaging, as well as Cloud Tasks for scheduling notifications. Each of these services is deeply integrated into GCP's ecosystem, which means that migrating Wave to another cloud provider, such as AWS or Azure, would not be a straightforward task.

To move away from GCP, I would need to find equivalent services on the new platform, which might not exist or might require different integration approaches. AWS, for instance, has its own set of services like AWS Step Functions and Amazon SNS for notifications, but they don't work in exactly the same way as GCP's offerings. This could mean rewriting parts of my application, adapting to new APIs, and possibly incurring unexpected costs or delays.

Additionally, the pricing structures of these services might change, making it more expensive to stay with a current provider. What starts as a cost-effective solution could become a financial burden if your application's usage grows significantly or if the provider alters its pricing model.

To mitigate these risks, it’s important to strike a balance between the convenience of managed services and the flexibility of self-hosted tools. You might choose to build critical components of your project using open-source tools or cloud-agnostic solutions that can be easily migrated across different platforms. But for other parts of your application, serverless will always be available.

Conclusion

Serverless infrastructure has significantly advanced, now enabling tasks that once required dedicated servers, often at a lower cost and with less maintenance.

This project highlights just a fraction of what’s possible with serverless technology today. I encourage you to explore these services and consider how they might offer advantages over traditional server setups in your next project.