Infrastructure

Creating a User-Centric Web Push Experience on the New twitter.com

By Charlie Croom
Thursday, 25 July 2019

Earlier this week, Jade highlighted some things we’ve done to make Twitter a Progressive Web App (PWA). Today, I wanted to get into a little more technical detail about one of our flagship features, and in particular what we did to ensure a great user experience across mobile and desktop.

For a long time, push notifications were a luxury reserved for native apps. They were one of the key technologies that app developers around the world reached for to make their native apps a richer, more real-time experience. Recently, this tool has been making its way to websites across the internet with the advent of “web push”.

This Tweet is unavailable
This Tweet is unavailable.

In 2015, Google Chrome began allowing websites to ask for permission to send push notifications to their users. Initially, these notifications were just pings to the site’s ServiceWorker. In 2016, they were able to carry data with them as well. And over the course of 2016 and 2017 a number of other features were added, bringing them closer to parity with native apps. Since then, nearly every major browser has started supporting web push notifications.

In fact, every day Twitter sends millions of web push notifications, these complement the real-time nature of Twitter and helps keep you up-to-date with what’s happening in the world and your network. As someone who uses Twitter (and other PWAs) myself, I’m aware that sometimes those push notifications have the potential to be overwhelming. While they can be a valuable tool for engaging with a sites’ users, too many notifications can cause frustration for users and get in the way.

Twitter is unique in that often times we notify someone about engagements on the same type of content multiple times, for instance multiple people could like your Tweet and each would be sent as a separate notification. For someone with relatively few followers like myself, these notification storms often come from a group Direct Messages. Conversations will heat up around a particular topic, and I’ll find my phone buzzing away every few seconds. In contrast, something like an e-commerce site might only send notifications about a topic once: a notification about a flash sale, an update when your order ships, or when that product you wanted comes back in stock. 

Social media and high-volume, real-time notifications present a unique set of challenges to make sure we aren’t annoying the people who use Twitter. How can we make sure people always have current pushes, while ensuring we aren’t overly persistent?

Web Push Today

The web push notification specification and Chrome-specific additions give us a few tools to achieve our goal: the “silent, renotify, and tag” parameters.

Silent: Don’t alert or vibrate the device when the notification is displayed.

Tag: Group displayed notifications by tag. If there’s a new notification with the same `tag` property as a currently displayed one, the new notification will silently replace the old one.

Renotify: Instead of tag replacements being silent (as above), the device will vibrate/alert again.

These three options give us a huge amount of control over how and when we alert a user. For example, Twitter’s relevance team sends some notifications as “silent” (both on the native apps and PWA). These are notifications that we don’t think are important or urgent enough to make you look at your phone right this second, but that you might want to have ready next time you do.

But we can go even further! If you have a high-activity conversation in Direct Messages, we might be buzzing your phone multiple times before you have time to take it out and look at it. Instead, we developed client-side aggregation for similar notifications.

 

Service Worker Push Aggregation

If you’ve used an Android device, you may be familiar with notification grouping. However on the web, we don’t have access to this affordance; we only have the tools mentioned above. So we have to be more clever with how we replicate the behavior, ensuring you don’t have dozens of notifications to swipe through when you look at your phone.

All of our aggregation is done on the client side, in the ServiceWorker, when a push is received. Trying to coordinate aggregation server-side can be extremely complex. This architecture allows our server to be straightforward: Whenever a notifiable event happens, we send a push. This is probably the simplest model for many sites and easy to use with any type of backend.

So with that in mind, a basic payload for a Direct Message push notification might look like the following:

This Tweet is unavailable
This Tweet is unavailable.
{
  title: "Charlie's Crew",
  body: "Any plans for dinner?",
  data: {
    url: "/messages/1234"
  }
}

This payload essentially becomes our call to the showNotification() method. However, this approach has a huge drawback: When we receive a new notification from the same Direct Message group, we’ll show another notification, and this can quickly overwhelm you.

Instead, we add a tag to each notification of the form: "[typeOfNotification]-[targetId]". This uniquely identifies each notification. In our example above, the tag would be “message-1234”. If someone Retweeted my Tweet with id 555, then that tag might be “retweet-555”.

This Tweet is unavailable
This Tweet is unavailable.
{
  title: "Charlie's Crew",
  body: "Any plans for dinner?",
  tag: "message-1234",
  data: {
    url: "/messages/1234"
  }
}

If we use that tag in conjunction with our previous JSON payload, we won’t get duplicates, and you’ll see a single notification for that group message. Nice! 

However, we’ve introduced a new problem. You will only ever see the most recent notification, even if you may have received 500 Retweets (a noteworthy moment), but when you pull out your phone to look, the notification simply reads “Charlie Retweeted your Tweet” (a somewhat lackluster moment). With our Direct Message example, the same problem occurs, you have no sense of how many messages you’ve missed and only see the most recent.

We can be a little smarter, though. Instead of blindly calling showNotification(), we check to see if any similar notifications are already displayed.

Keeping State Across Notifications

This Tweet is unavailable
This Tweet is unavailable.
self.registration.getNotifications({ tag }).then(notifications => {
  const openNotification = notifications[0];
  const totalCount = (openNotification ? openNotification.data.totalCount : 0) + 1;
  return self.registration.showNotification(notification.title, { ...notification, data: { ...notification.data, totalCount } });
});

The data property of each notification is essentially a place we can store random information. Here, it’s easy to see that as each new notification comes in, we keep an accurate count of the total messages accrued.

Now the problem is how to convey that information to you. The simplest approach is to just change the text to say: `X new messages \n ${notification.body}`. But this would only work for people in English. And the code to deal with proper internationalization detection and support — along with all the phrases we’d potentially need — is too complex, especially for the service worker. Instead, our push service, which already handles internationalization of the title and body, can again use that same helpful data attribute. With this, our new push (translated into English) becomes:

This Tweet is unavailable
This Tweet is unavailable.
{
  title: "Charlie's Crew",
  body: "Any plans for dinner?",
  tag: "message-1234",
  data: {
    lang: "en",
    multi_body: "{totalCount, number} new {totalCount, plural, one {message} other {messages}}"
    url: "/messages/1234"
  }
}

Or for a Retweet:

This Tweet is unavailable
This Tweet is unavailable.
{
  title: "jack Retweeted",
  body: "I just love web push notifications",
  tag: "retweet-5678",
  data: {
    lang: "en",
    multi_body: "{totalCount, number} {totalCount, plural, one {Retweet} other {Retweets}} including jack"
    url: "/charliecroom/status/5678"
  }
}

We run the multi_body through stripped-down a version of “intl-messageformat” with only plural rules included, which allows us to properly interpolate the totalCount we’ve been accumulating. And finally, after all that, we have our aggregation system!

We can apply this same technique to be even more thrifty with how much notification tray space we take up. If you ever have several “interaction” notifications open (e.g. “Charlie liked your Tweet,” “jack replied to your Tweet,” etc.). We collect all of those, bundle them together, and simply show “N new interactions”. Once the number of notifications reaches a certain threshold, they can be overwhelming and difficult to process. Often times it’s best to simply take you to your notifications instead, which has a better UI for digesting lots of interactions.

Other Tricks

Push notifications are sometimes superfluous, or distracting from what you’re already doing. One optimization we can make is to check that the page we’re pointing you to, isn’t one you’re already on. The most common use case for this functionality is Direct Messages. If the conversation is already open and focused on your device, then you probably don’t need a push about it since you’re already seeing the content.

 

This Tweet is unavailable
This Tweet is unavailable.
const getFocusedClient = () => self.clients.matchAll({ type: 'window' }).then(
  windows =>
    windows.filter(
      client => client.focused && client.frameType === 'top-level' && client.visibilityState === 'visible'
    )[0]
);

const shouldShowNotification = (dataUri) => getFocusedClient().then(client => {
  if (!client || !dataUri) {
    return true;
  }

  let currentUrl;
  try {
    currentUrl = new URL(client.url);
  } catch (e) {
    return true;
  }

  // If it's a Direct Message, we suppress the notification when you're 
  // in either DMInbox or the conversation.
  if (dataUri.match(/^\/messages/)) {
    return !(currentUrl.pathname === '/messages' || currentUrl.pathname === dataUri);
  } else {
    return !(currentUrl.pathname === '/notifications');
  }
});

In addition, sites supporting web push can also help keep your browser tabs clean and organized. If you already have Twitter open, maybe in the background or an inactive tab, then when you click on a notification, we simply focus that tab and navigate to the appropriate path instead of opening a new tab.

 

Give the People What They Want

Our goal is to make sure that we make a great experience for the people who use Twitter. You might find some of this is overkill for your site, or that it doesn’t fit your needs. Maybe you have a use case where opening pushes in a new tab is the ideal experience. That’s great! These ServiceWorker tricks are just one piece of the puzzle of trying to make a great experience for people across the web.

With great web push notifications, comes great power. Hopefully this post has shown some insight into how we thought about wielding that at Twitter. It’s amazing to see how far push notifications have come and the user-centric experiences they can help create, but it’s important that we, as developers, think about how to make sure they don’t become a nuisance to our users and continue to be a valuable part of our sites.

This Tweet is unavailable
This Tweet is unavailable.
@charliecroom

Charlie Croom

‎@charliecroom‎

Staff Software Engineer

Only on Twitter