Scaling Web Push Notifications with Azure Functions

One of my talks is talk about native real-time technologies like WebSockets, Server-Sent Events, and Web Push in ASP.NET Core. In that talk, I briefly go through the subject of scaling applications based on those technologies. For Web Push, I mention that it can be scaled with help of microservices or functions, because it doesn't require an active HTTP request/response. After the talk, I'm frequently asked for some samples of how to do that. This post is me finally putting one together.

Sending Web Push Notifications from Azure Functions

I've written a series of posts about Web Push Notifications. In general, it's good to understand one level deeper than what you're working at (so I encourage you to go through that series), but for this post, it's enough to know that you can grab a client from NuGet. This client is all that is needed to send a notification from Azure Function, so let's quickly create one.

The function will use Azure Cosmos DB as a data source. Whenever an insert or update happens in NotificationsCollection, the function will be triggered.

public static class SendNotificationFunction
{
    [FunctionName("SendNotification")]
    public static void Run(
        [CosmosDBTrigger("PushNotifications", "NotificationsCollection",
        LeaseCollectionName = "NotificationsLeaseCollection", CreateLeaseCollectionIfNotExists = true,
        ConnectionStringSetting = "CosmosDBConnection")]
        IReadOnlyList<PushMessage> notifications)
    {

    }
}

You may notice that it's using an extension for Azure Cosmos DB Trigger described in my previous post, which allows for taking a collection of POCOs as an argument.

The second piece of data kept in Cosmos DB is subscriptions. The function will need to query all subscriptions to send notifications. This is best done by using DocumentClient.

public static class SendNotificationFunction
{
    private static readonly Uri _subscriptionsCollectionUri =
        UriFactory.CreateDocumentCollectionUri("PushNotifications", "SubscriptionsCollection");

    [FunctionName("SendNotification")]
    public static void Run(
        ...,
        [CosmosDB("PushNotifications", "SubscriptionsCollection", ConnectionStringSetting = "CosmosDBConnection")]
        DocumentClient client)
    {
        if (notifications != null)
        {
            IDocumentQuery<PushSubscription> subscriptionQuery =
                client.CreateDocumentQuery<PushSubscription>(_subscriptionsCollectionUri, new FeedOptions
                {
                    EnableCrossPartitionQuery = true,
                    MaxItemCount = -1
                }).AsDocumentQuery();
        }
    }
}

Now the function is almost ready to send notifications. Last missing part is PushServiceClient. An instance of PushServiceClient is internally holding an instance of HttpClient. This means that improper instantiation antipattern must be taken into consideration. The simplest approach is to create a static instance.

public static class SendNotificationFunction
{
    ...
    private static readonly PushServiceClient _pushClient = new PushServiceClient
    {
        DefaultAuthentication = new VapidAuthentication(
            "<Application Server Public Key>",
            "<Application Server Private Key>")
        {
            Subject = "<Subject>"
        }
    };

    [FunctionName("SendNotification")]
    public static void Run(...)
    {
        ...
    }
}

Sending notifications is now nothing more than calling RequestPushMessageDeliveryAsync for every combination of subscription and notification.

public static class SendNotificationFunction
{
    ...

    [FunctionName("SendNotification")]
    public static async Task Run(...)
    {
        if (notifications != null)
        {
            ...

            while (subscriptionQuery.HasMoreResults)
            {
                foreach (PushSubscription subscription in await subscriptionQuery.ExecuteNextAsync())
                {
                    foreach (PushMessage notification in notifications)
                    {
                        // Fire-and-forget
                        _pushClient.RequestPushMessageDeliveryAsync(subscription, notification);
                    }
                }
            }
        }
    }
}

And that's it. A very simple Azure Function taking care of broadcast notifications.

Improvements, Improvements ...

The code above can be better. I'm not thinking about the Cosmos DB part (although there are ways to better utilize partitioning or implement fan-out approach with help of durable functions). The usage of PushServiceClient is far from perfect. Static instance makes configuration awkward and may cause issues due to static HttpClient not respecting DNS changes. In the past, I've written about using HttpClientFactory and making HttpClient injectable in Azure Functions. The exact same approach can be used here. I will skip the boilerplate code, it's described in that post. I'll focus only on things specific to PushServiceClient.

First thing is an attribute. It will allow taking PushServiceClient instance as a function parameter. It will also help solve the configuration problem, by providing properties for names of settings with needed values.

[Binding]
[AttributeUsage(AttributeTargets.Parameter)]
public sealed class PushServiceAttribute : Attribute
{
    [AppSetting]
    public string PublicKeySetting { get; set; }

    [AppSetting]
    public string PrivateKeySetting { get; set; }

    [AppSetting]
    public string SubjectSetting { get; set; }
}

The second thing is a converter which will use HttpClientFactory and instance of PushServiceAttribute to create a properly initialized PushServiceClient. Luckily PushServiceClient has a constructor which takes HttpClient instance as a parameter, so it's quite simple.

internal class PushServiceClientConverter : IConverter<PushServiceAttribute, PushServiceClient>
{
    private readonly IHttpClientFactory _httpClientFactory;

    public PushServiceClientConverter(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
    }

    public PushServiceClient Convert(PushServiceAttribute attribute)
    {
        return new PushServiceClient(_httpClientFactory.CreateClient())
        {
            DefaultAuthentication = new VapidAuthentication(attribute.PublicKeySetting, attribute.PrivateKeySetting)
            {
                Subject = attribute.SubjectSetting
            }
        };
    }
}

The last thing is IExtensionConfigProvider which will add a binding rule for PushServiceAttribute and tell the Azure Functions runtime to use PushServiceClientConverter for providing PushServiceClient instance.

[Extension("PushService")]
internal class PushServiceExtensionConfigProvider : IExtensionConfigProvider
{
    private readonly IHttpClientFactory _httpClientFactory;

    public PushServiceExtensionConfigProvider(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public void Initialize(ExtensionConfigContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        //PushServiceClient Bindings
        var bindingAttributeBindingRule = context.AddBindingRule<PushServiceAttribute>();
        bindingAttributeBindingRule.AddValidator(ValidateVapidAuthentication);

        bindingAttributeBindingRule.BindToInput<PushServiceClient>(typeof(PushServiceClientConverter), _httpClientFactory);
    }

    ...
}

I've skipped the code which reads and validates settings. If you're interested you can find it on GitHub.

With the above extension in place, the static instance of PushServiceClient is no longer needed.

public static class SendNotificationFunction
{
    ...

    [FunctionName("SendNotification")]
    public static async Task Run(
        ...,
        [PushService(PublicKeySetting = "ApplicationServerPublicKey",
        PrivateKeySetting = "ApplicationServerPrivateKey", SubjectSetting = "ApplicationServerSubject")]
        PushServiceClient pushServiceClient)
    {
        if (notifications != null)
        {
            ...

            while (subscriptionQuery.HasMoreResults)
            {
                foreach (PushSubscription subscription in await subscriptionQuery.ExecuteNextAsync())
                {
                    foreach (PushMessage notification in notifications)
                    {
                        // Fire-and-forget
                        pushServiceClient.RequestPushMessageDeliveryAsync(subscription, notification);
                    }
                }
            }
        }
    }
}

You Don't Have to Reimplement This by Yourself

The PushServiceClient binding extension is available on NuGet. I have also pushed the demo project to GitHub. Hopefully it will help you to get the best out of Web Push Notifications.