You have unread messages
Sending email notifications with Firebase Functions and Sendgrid
Imagine creating a profile on this job searching site that promises to connect you to industry insiders. A recruiter sees it and messages you with a dream opportunity. But, you never see the message because you signed up to this site for fun and haven't logged back in to check your messages for weeks. That was Ninjobu, at least up until a few days ago.
In the last post, I wrote a bit about how I set up the chat system so recruiters could communicate with candidates. One significant omission was new message notifications. Since we have a web app here, it's unlikely people stay active on the platform for long, the way you might do on social media or IM. And, arguably, user communication is a crucial part of asynchronous job searching. It made sense for the next feature to be a way to let users know when someone contacts them.
My goal was to write a simple solution that would notify users when they have new unread messages while not being too naggy. The resulting structure ended up straightforward, implemented with just two Firebase functions.
As mentioned in the previous post, our database consists of chat documents that store the last message timestamp and when each chat participant last viewed that message. With this information, we can easily decide which users we should notify.
First, I added a new Firebase function that triggers on writes for each chat document. The onWrite trigger executes for the creation, updates, and deletions of documents. I ignore the deletion case, but I know the last message timestamp will have updated during the creation and update events.
When a chat is updated, it will have a
lastMessageTime timestamp and an array
lastSeenTime with two entries: one timestamp for each of the two chat participants, representing when they last saw the chat. If any of the
lastSeenTime timestamps are older than the message, we record the chat id in a document
misc/chats_to_notify for later. Firebase's FieldValue.arrayUnion utility lets us atomically add unique entries to an array.
With the above function running every time a chat document is updated, we will end up with a list of chat ids in our
misc/chats_to_notify document. The next step is to go through these chats and send emails to the participants, as required. We do this with a second function that we schedule at a set interval.
Being tightly integrated with GCP, Firebase offers many features from Google Cloud wrapped in simple-to-use interfaces. One example is scheduling functions that run at predefined intervals, using Google Cloud Pub/Sub and Scheduler behind the scenes. The function that handles our email notifications is a bit chunkier, so let's split it into smaller parts.
I set up the function to run once every two hours, quickly done with the App Engine cron.yaml syntax.
Start by retrieving the
misc/chats_to_notify document where we have the ids for our updated chats, and early out if the document is invalid or the list of chats is empty.
Once we have our chats, I chose to process only up to 100 of them on each invocation. Firebase functions have a running time limit of 60 seconds, and I don't want the function to time out and not send any emails if the list of updated chats is too long. However, my choice is a bit premature and speculative. The 100 chats limit was chosen arbitrarily, and Firebase allows the function timeout to be configured to up to 9 minutes. I'm also unsure how the function itself will scale with a significantly larger array of chat ids. I will likely have to tweak this once I have more data. But for now, processing 100 chats every 2 hours seems reasonable based on the website's current activity.
While looping over each chat, we add its id to the
processed array and later use it to remove the entry from the
misc/chats_to_notify document. Next, we retrieve the chat document data and validate the required fields. We need
lastMessageTime to exist and the
members array to contain the two chat participants' UIDs. We then save each member's UID if their
lastSeenTime is older than the
lastMessageTime. This check is important because, between the time the chat id was recorded in the
misc/chats_to_notify document and the time this function runs, each member of the chat may have already seen the last message. We don't want to send an email notification for a read message. Our onWrite function for the chat document only adds chat ids to the notification list and doesn't remove them.
A thing to keep in mind here is, if we have 100 chats where the participants have both seen the messages, this function will process those chat entries and not send any emails until the following invocation 2 hours later. I'm not entirely happy with this, but I consider this part of the code temporary until I have more data on how many emails one run of the function can process.
We now have a list of UIDs for users that need to receive a notification email. For each UID, we get the user's email from the Firebase Auth module and create a personalization entry. Personalizations are a Sendgrid feature that lets us send the same email to multiple recipients with a single API call. It also ensures that each recipient will only see their email address in the to field and avoids catastrophic invasion of privacy.
Finally, we remove all the chat ids we have processed from the
misc/chats_to_notify document to avoid sending multiple emails to the same folks. The FieldValue.arrayRemove feature allows us to do this quickly with a single call.
Speaking of the array with chat ids, it may be worth noting that at some point, this may become a bottleneck. Firestore document sizes have a limit of 1 MiB. If we assume the auto-generated chat ids continue to be 20 bytes each like they are at the moment, we have room for a bit over 50,000 entries in the array before we blow the size limit. Additionally, Firebase has a soft limit of one write per second to the same document that they don't recommend you go over to avoid contention errors. It's a soft limit in the sense that it shouldn't cause issues in short bursts, but something to keep in mind. If there's loads of chat activity on the site, the
misc/chats_to_notify document will be hammered and potentially go over the one write per second limit. Once we hit these problems, however, congratulations are in order, most likely.
Thank you for reading this far. If you are a software engineer open to new opportunities but not actively looking for work, try out Ninjobu! Create a profile, let recruiters know what job and salary you'd like, and who knows what might happen.
Until next time.