Bring fans back for the closing minutes

In this tutorial, a monitoring service tracks who's watching the match with Presence on the channel game.stream. It notices when one fan's client leaves that channel, confirms that specific fan is actually gone, and sends that fan alone a push alert on their own channel through Mobile Push Notifications, timed for when the match reaches a moment worth returning for. Every other fan still watching keeps watching, undisturbed. Presence tells you who isn't watching anymore, and push is how you reach that one fan without reaching anyone else. Presence must be enabled on your keyset for any of this to work, and a fan counts as watching only while their client holds an open subscription to game.stream.

What you'll build​

fan-viewer.js stands in for a fan's device. It subscribes to game.stream to count as watching, and registers for push on that fan's own alert channel. fan-re-engagement.js subscribes to game.stream with presence events, so Presence tells it when a fan leaves. It confirms that the fan has left, then publishes a moment alert to that fan's own channel, game.moment-alerts.<userId>. Mobile Push Notifications hands the alert to Apple Push Notification service (APNs) or Firebase Cloud Messaging (FCM), which delivers it to that one fan. Fans still watching receive nothing.

Before you begin​

You need:

  1. Node.js 22 or later.
  2. A PubNub account and your own keyset. A keyset is the set of publish, subscribe, and secret keys that identifies your application to the PubNub network. If you don't have one, follow Set up your account to create one. You need its publish key and subscribe key.
  3. Presence enabled on that keyset. Presence must be enabled on the keyset, and new keysets have it enabled by default.
  4. Mobile Push Notifications enabled on that keyset, with a provider credential uploaded for each platform you target. Send a push alert when a goal is scored covers the exact credential each provider needs.
  5. PubNub SDKs set presenceTimeout to 300 seconds by default, which is how long PubNub waits without a heartbeat before marking a client offline. That's how quickly a fan who disappears without unsubscribing counts as gone.

Set up the project​

Create a new directory and install the PubNub JavaScript SDK:

mkdir fan-re-engagement-tutorial
cd fan-re-engagement-tutorial
npm init -y
npm install pubnub

This tutorial uses import syntax, so add "type": "module" to package.json. Then create two files. fan-re-engagement.js is the monitoring service. It never represents a fan, only the process watching for one to leave:

import PubNub from 'pubnub';

const pubnub = new PubNub({
publishKey: 'YOUR_PUBLISH_KEY',
subscribeKey: 'YOUR_SUBSCRIBE_KEY',
userId: 'match-service',
});

fan-viewer.js stands in for a fan's own client and device. You'll run it more than once, each time as a different fan, so it reads which fan it is from the command line rather than hard-coding one:

import PubNub from 'pubnub';

Replace YOUR_PUBLISH_KEY and YOUR_SUBSCRIBE_KEY with the keys from your keyset in both files. Keeping the monitoring service on its own userId, match-service, matters: it's never the connection whose absence it's trying to detect, so it can watch every fan, including one that later runs on the same machine for testing, without ever being the fan it's watching for.

Use a channel that means "watching"​

A fan's client subscribes to game.stream for as long as the stream is on their screen, and nothing else runs on that channel. That makes its occupancy your audience figure, and a leave or timeout event on it your signal that a fan stopped watching.

You wouldn't get the same signal from game.chat. A fan can watch the entire match without ever sending a chat message, so chat activity undercounts the audience and misses every silent viewer.

leave and timeout both mean a fan is gone, but they mean it differently. leave fires when a client unsubscribes from the channel, a deliberate action such as closing the app. timeout fires when a client goes silent for longer than the channel's heartbeat timeout allows, which usually means a dropped connection rather than a choice to stop watching. Presence events covers both in full.

Presence on game.stream reports which fan left. The match service then publishes only to that fan's own game.moment-alerts.<userId> channel, so push reaches that one fan, and every fan still watching receives nothing.

Count who is watching​

1

Add this to fan-re-engagement.js. This calls Here Now on game.stream and reads back the occupancy count. Use it to seed a number when a screen first loads or when you reconnect, not as your only source of truth for who's watching right now.

Give every fan their own alert channel​

1

Add this to fan-re-engagement.js. sendMomentAlert publishes to game.moment-alerts.<userId>, one channel per fan, instead of one shared channel every registered device listens to. That's what makes it possible to reach one specific fan at all: a shared channel has no way to name a single recipient, so anything published there reaches every device registered on it, whether or not that particular fan is the one who left. This is a function, not code that runs on its own. The next step is what decides when it's appropriate to call.

Alert a fan only if they actually left​

1

Add this to fan-re-engagement.js, after Give every fan their own alert channel. whereNow answers a different question than Here Now does: given a User ID, which channels is that User ID currently subscribed to. It's not an occupancy check on a channel. It's a lookup on a specific client. notifyIfAbsent uses it to confirm that userId isn't in the list of channels it's subscribed to before it calls sendMomentAlert, so a push notification only ever goes out once you've confirmed, separately from whatever event made you suspect it, that the fan is actually gone. Calling sendMomentAlert unconditionally, without this check, would alert a fan regardless of whether they'd actually left.

Watch fans leave, and alert only the one who did​

1

Add this to fan-re-engagement.js, after the code above. This is an event, not a poll. You subscribe to game.stream with presence events enabled once, and PubNub calls your handler every time a fan's client leaves or times out, instead of you asking again and again. The handler calls notifyIfAbsent(event.uuid), so the fan it checks and potentially alerts is always the specific fan named in that event, never a hard-coded one and never every fan currently registered. Receive presence events covers the subscription option and the handler in full.

At a small audience, game.stream reports every leave and timeout individually, each one carrying the fan's User ID. Below the Announce Max threshold, a channel emits individual join, leave, and timeout events. At or above it, the channel emits periodic interval events instead, while state-change stays individual in both modes. Once a match draws a large enough audience to cross that threshold, the individual events stop. You no longer get a leave or timeout for any specific fan, only a periodic total. That changes whether you can identify the fan who just stopped watching at all, not only how you're told about it.

Presence Deltas can add a list of User IDs back to that periodic event. PubNub drops those lists whenever they would push the event past the message size limit, and reports a refresh flag instead. Design this flow so it still works once it's reduced to a raw count, because at your largest audiences that's what it becomes.

Set up a fan's viewer and device​

1

Add this to fan-viewer.js. It reads a User ID from the command line, defaulting to fan-a, subscribes to game.stream under that identity to represent watching, registers a device for game.moment-alerts.<userId>, that fan's own alert channel, and subscribes to that same channel so this script can show you, live, whether an alert actually reached it. Send a push alert when a goal is scored covers the registration call itself, the device token, and the token-refresh obligation. The mechanics here are identical, and only the channel name changes.

Pressing Ctrl+C doesn't quit this script. It unsubscribes from game.stream only, so this fan stops watching while the process, and its alert-channel subscription, stay up. That's deliberate: it's what lets you see, in this same terminal, whether a moment alert reaches a fan after they've left the stream. Close the terminal, or send a second interrupt, to actually stop the process.

Run it​

You need three terminals in fan-re-engagement-tutorial. In the first, start the monitoring service:

node fan-re-engagement.js

It prints the current occupancy, 0, and waits. In the second terminal, start a fan who stays for the rest of the test:

node fan-viewer.js fan-a

In the third, start a fan who's about to leave:

node fan-viewer.js fan-b

Back in the first terminal, the occupancy count only reflected whoever was watching when it was read, so it won't update on its own. The presence handler is what reacts to what happens next. In the third terminal, running fan-b, press Ctrl+C:

fan-b left game.stream, but is still reachable for a moment alert

In the first terminal, the monitoring service logs the presence event and the outcome of its check:

fan-b stopped watching, and 2 fans remain
fan-b is not subscribed to game.stream, so sending a moment alert
moment alert published to game.moment-alerts.fan-b at timetoken: 17123456789012345

That count is one higher than the number of fans still watching, because fan-re-engagement.js itself holds an open subscription to game.stream in order to receive presence events on it, and PubNub counts that subscription too. If you need a fan-only occupancy figure elsewhere in your application, subtract your own known monitoring connections from what Here Now reports.

In the third terminal, still running because only game.stream was unsubscribed, the alert arrives:

fan-b received a moment alert: { message: 'Two minutes left, and it is still 2-2.', ... }

The second terminal, running fan-a, prints nothing. fan-a never left game.stream, so notifyIfAbsent was never even called for fan-a, let alone allowed to publish to game.moment-alerts.fan-a. Press Ctrl+C in each terminal to stop it.

Decide how often to send one​

How often you send a moment alert is your call, not a limit PubNub enforces. Weigh these before you decide:

  • Send few alerts. Every alert spends some of the fan's attention. Spend that on moments that are genuinely worth returning for, not every moment that's merely interesting.
  • Send with lead time. An alert that arrives after the moment has passed gives the fan nothing to act on. Send it early enough that opening the app still means something.
  • Let the fan choose. A separate registration per alert category means a fan can keep goals and drop everything else, rather than accepting your judgment about what's worth a notification.

One worked example, not a PubNub-recommended figure: an application could cap moment alerts at one or two per match and send them 30 to 60 seconds ahead of the moment. Treat that as a starting point to test against your own fans' behavior, not as a target.

What happened​

  1. fan-re-engagement.js subscribed to game.stream with presence events enabled, under its own match-service identity, which is what makes a fan's leave or timeout visible to it.
  2. Two fan viewers subscribed to game.stream, each under its own User ID, and each registered its own device on its own game.moment-alerts.<userId> channel.
  3. fan-b unsubscribed from game.stream only, and fan-re-engagement.js received that leave event.
  4. The handler called whereNow for fan-b specifically, confirmed fan-b was no longer subscribed to game.stream, and only then published an alert, targeted at game.moment-alerts.fan-b alone.
  5. fan-b's still-open subscription to its own alert channel received that publish. fan-a, still watching and never checked, received nothing.

Presence told the service who stopped watching. whereNow confirmed it was actually true for that one fan. Push is what let the service reach that fan, and no one else.

Next steps​

Was this page useful?

Last updated on