05 Jan 2023

Serverless scheduled push notifications with Firebase and Cloud Tasks

Serverless infrastructure for timer notifications.

Introduction

As a developer with many small and experimental projects, the biggest benefit of serverless computing is the low cost of commitment; I can create many projects without worrying about server costs and scaling issues, while freeing me up to explore as many ideas as I want.

The name “serverless” is a misnomer as servers still exist in a data center somewhere. However, compute resources are allocated on-demand on a task-by-task basis. While this provides many advantages, it also means that there are no dedicated machines executing long-running code or continuous processes.

However, it is often useful to schedule a specific task for the future, such as sending push notifications. Firebase provides scheduled functions for regularly recurring tasks much like cron jobs, but these do not apply for tasks that only need to run infrequently at specific and irregular times.

In this article, I’ll dive into how I used Google Cloud Platform (GCP) and its serverless infrastructure for scheduling and sending push notifications for Wave, my simple pomodoro tracker. I’ll also give examples of how I connected my React Native mobile application to my infrastructure.

Implementation

When building Wave, I needed a low-cost solution to notify users when their timers complete. I was already using Firebase for Cloud Firestore — a real-time NoSQL cloud database hosted in GCP — so continuing to use GCP was an easy choice.

Sending notifications

GCP offers a simple serverless messaging service called Firebase Cloud Messaging, or FCM for short. FCM is great because it handles many notification concerns:

  1. it integrates easily with Apple Push Notifications for iOS, and works with many platforms like Android, Web, C++, and more,
  2. it allows notifications to be distributed to either a single device, a group of devices, or all devices subscribed to a topic,
  3. it is a serverless service which eliminates the cost of running a dedicated server for infrequent notifications.

Creating the interface

First, we want to create a cloud function that will trigger the delivery of our notifications. This allows us to run server-like code without a dedicated server and provides a single interface for triggering notifications. Since we already use GCP, we can define our cloud function with GCP Cloud Functions. In addition to some minor compute costs, Cloud Functions are free for the first 2 million invocations per month and only US$0.40 per million requests after that.

☁️

Cloud functions are another serverless concept that allow us to run code in an on-demand server environment.

import * as functions from 'firebase-functions';

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

For information on how to setup a Cloud Functions project locally, refer to this guide.

Multi-device support

Since Wave supports multi-device sync, a single notification needs to be sent to multiple devices. The easiest way to do this is to associate every device to a user.

This is achieved by storing an array of device tokens for a given user ID in Firestore. Wave reads the device token when a user logs in on a new device and adds the token to a document in /devices/{userId}. If a user is not logged in, they are given an anonymous user ID in its place.

/// 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 read 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]);
}

// Update Firestore.
async function updateDeviceTokenMemory(
  userId: string,
  token: string,
  // Allows for replacement if needed.
  oldToken?: string,
) {
  // ...
}

When a notification needs to be sent, the cloud function can simply read the same document for a given user ID to get the list of associated device tokens, then use those tokens for a multicast notification.

/// In a 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.

Now that the cloud function for sending notifications is complete, we can look into scheduling.

Scheduling notifications

Timer design

Wave uses a bandwidth-minimal approach for its timer and only updates the database whenever 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. Conversely, when the timer is paused, we find the associated task to delete.

To do so, we use GCP’s Cloud Tasks. Cloud Tasks is a managed serverless service that allows for the execution and dispatch of asynchronous tasks. We’ll mainly focus on its ability to schedule tasks for the future.

Setting up Cloud Tasks

Cloud Tasks is a billed service so we’ll have to enable billing on our project. For those who are budget conscious, don’t worry as Cloud Tasks is very cheap; we get 1 million free requests per month and only pay US$0.40 per million requests after that.

Once we enable the Cloud Tasks API, we need to create a queue for our notifications. To do so, install the gcloud CLI, configure it for our project, and run the command:

gcloud tasks queues create {queue_name}

Creating a task

We’ll create another cloud function to schedule tasks with the @google-cloud/tasks npm package. Instead of using a HTTP triggered function like before, we listen to our Firestore collection for changes.

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

To schedule our notification, we’ll create a task on our 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 will send a HTTP POST request to our previously created sendNotification cloud function with the appropriate payload, thereby triggering the notification for the given user.

Deleting a task

To delete a task, we simply need to pass the task’s name to tasksClient.deleteTask. Cloud Tasks allows tasks to be given specific names, so we may be tempted to use the user ID as the task name for easier lookup and deletion. However, a named task cannot be created if a previous task with the same name existed within the past 30 minutes. If we use user IDs as task names, we effectively rate-limit our notifications to one notification per user every 30 minutes.

To work around this, we continue to create tasks with a randomized name, then store the name in our database for the user ID.

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 we need to clear the notification for a given user, we simply look up the task name in our database.

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});
}

Conclusion

Serverless infrastructure has come a long way. It now supports many tasks that were once only possible with dedicated servers, and it often costs less to run and maintain.

This project is a simple demonstration of what is possible today and I encourage you to explore and evaluate the services available against traditional servers for your next project.