Building a Push Service that Scales to 1 Million Subscribers with Firebase

Kevin Basset
JavaScript in Plain English
6 min readJun 24, 2021

--

Within the web of functionality we typically call Progressive Web App (PWA), there’s one that I’ve always found particularly interesting: push notifications.

I built Progressier to help developers make any site a PWA without having to write any code. So, in the process, I had to build a push solution. And oh boy…that wasn’t easy.

In this article, I’ll walk you through some of the challenges we encountered during the development process with Firebase/GCP and how we overcame them. Note that this article does not contain any code snippets.

Challenge 1: Personalization vs marketing

Push notifications…what are they for? Well, there are two main occasions to send a push notification to a user:

A. A personalized in-app event, e.g. someone commented on a post that they wrote.

B. A marketing campaign, e.g. you just released a new feature for all your users

Some solutions (like OneSignal or Subscribers.com) are particularly well-tailored for the second use case. You send the same notification to a large group of subscribers. The first use case requires personalization — there will often only be a single recipient to each particular notification.

Progressier is meant to be an all-in-one solution for PWA features. So you should be able to send a notification to one particular user, or to your entire userbase — just as easily.

Progressier has a dashboard that lets you preview your notification on different devices

It’s important to note than with use case B, notifications are sent predictably. So you can just log in to the Progressier dashboard and send a notification to all your subscribers when you have something push-worthy to say (a feature release, your Black Friday sale, etc).

With use case A, your approach must be programmatic. You don’t know when users will comment on a post of another user. So you need an API. And you need to integrate that API with your own code. Whenever a push-worthy event happens (a user sent you a private message, your credit card expired, someone liked your post, etc), you call our API with the details of the notification.

Because Progressier is a no-code solution, it has to accommodate both use cases. So when you sign up for Progressier, you get access to both a dashboard and an API. So no matter how proficient you are with code, the solution will work for you.

The challenge was to not repeat ourselves. It’s generally a good idea to design your architecture so that it requires as little effort as possible to maintain. So we first built the API. Then, we designed the dashboard around the API. So behind the scenes, the dashboard is really just a frontend for the API. This actually makes testing the API much easier.

Neat little trick: the dashboard lets you preview what the request would look like if you directly used the API. And when you click “Send now”, this is the exact request it will make.

Challenge 2: Connecting with user data

Most push services allow you to segment users by geography, browser or device. Segmenting is often a bottleneck. You‘re more likely to send a notification to a user with a specific email, a specific type (e.g. free vs paid) or a specific plan.

Once you’ve embedded Progressier in your app (one line of code, really), you have access to a method that lets you add your own user data in the matching user profile in Progressier. So when it’s time to send a notification, you can target users with this data you’ve just connected. After a user has logged in, all you have to do is call the following method in your client-side code.

Call progressier.add with your user data to add it to Progressier

The challenge was that Firestore has limited data querying option. So for now we only support exact match queries. You can send notifications to anyone whose user-type is “free”, whose email is “john@example.com” or whose country is “Germany”. Or a mix of that. But for example, you can’t target users whose subscription fee is greater than 10 dollars. And you can’t add anything that’s not a number/boolean/string in user profiles. Limited but still more than what most push services offer — which often just let you subscribe users to topics.

Challenge 3: Keeping count of subscribers

Our analytics feature lets you track subscriptions and app installations over time. Despite looking fairly simple, behind the scenes, it’s really complex.

First off, there’s no actual method for detecting app installs. And what constitutes an app installs differs across browsers. So that’s mostly a guessing game, and while there’s no perfect solution, our approach is a pretty good estimation of the number of users who actually install a given PWA on their device.

Secondly, some of our customers have 1,000+ new subscribers signing up for push notifications in their app every day. So our analytics can’t just count all subscribers whenever our customer opens the analytics page. This would result in very pricey and slow queries.

Here is what concretely happens when someone signs up for push notifications:

  • The endpoint of the push subscription is saved in the user profile in Progressier
  • We randomly increment one of the 500 distributed counters we’ve created for that particular day. We need many counters because it’s not recommended to update a Firestore document more than once per second. So by creating 500 counters per day per app, we’re spreading the updates on many different documents.
  • At the end of every day (around 00:05 GMT), a scheduled function retrieves all 500 counters and turn them into just one, before saving the final number of subscriptions/installs for that day in a new document
  • During that scheduled function, we also increment a global counter that tracks the total number of subscriptions/installs for that particular app. So you can see what your totals are without having to load each day.

Challenge 4: Sending to 1,000,000 users

Here is a little reminder of how sending a push notification works behind the scenes:

  1. You ask a user to allow notifications in your app. If they click “Allow”, you’ll get a unique endpoint. You save this endpoint against a user profile in your database.
  2. To send a notification to this particular user, you query the user profile from your database and make an HTTP request to that endpoint with the details of the push notification.

All in all, sending a push notification to a user isn’t much more complicated than making a POST request to any random endpoint. What’s orders of magnitude more difficult is sending to multiple users simultaneously.

20 users? Still easy. You just loop through your user list and issue 20 HTTP requests.

1,000,000 users? You need a completely different strategy.

I won’t reveal the exact recipe of the secret sauce, but in Progressier, it involves Cloud Tasks, Firestore Triggers, and Firestore pagination.

Once you click Send in the Progressier dashboard (or using our API), the details of the notification are saved in the database. A Firestore trigger listens to this event, and then calls a function that retrieves a paginated list of all users that should receive it — based on your targeting.

Then for each user in the paginated list, the server creates a task in a Cloud Tasks queue. Then move on to the next page of users.

All tasks in the Cloud Tasks queue are then processed one by one at a predefined rate (so that we don’t crash the whole thing by sending it to too many users at once!).

Finding the right infrastructure for this was challenging. And testing even more. Fortunately, we also own an app with more than 100,000 subscribers we can test Progressier on.

Conclusion

So, if you feel like building a push service for yourself or other developers. Hopefully, this article will help you understand some of the challenges that you’ll face.

If you don’t feel like spending time and resources building one, you can also sign up for Progressier and have it up and running on your website in ~5 minutes. 😉

--

--