---
source_url: https://www.pubnub.com/docs/use-cases/sports-media-entertainment/fan-re-engagement
title: Bring fans back for the closing minutes
updated_at: 2026-09-30T07:20:08.000Z
---

# Bring fans back for the closing minutes

## Documentation index

To discover more PubNub resources:

1. Fetch [PubNub's llms.txt](https://www.pubnub.com/llms-full.txt) for a list of available pages in Markdown format.
2. Identify relevant URLs from that index.
3. Fetch the target pages.

Do not assume a path exists, always check the index first.

In this tutorial, a monitoring service tracks who's watching the match with [Presence](https://www.pubnub.com/docs/presence/overview.md) 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](https://www.pubnub.com/docs/integrations/mobile-push-notifications/overview.md), 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.

```mermaid
sequenceDiagram
    participant Viewer as fan-viewer.js
    participant Stream as game.stream
    participant Service as fan-re-engagement.js
    participant Alerts as game.moment-alerts.fan-b
    participant Push as APNs or FCM

    Viewer->>Stream: subscribe, so the fan counts as watching
    Viewer->>Alerts: register device for push
    Service->>Stream: subscribe with presence events
    Viewer-->>Stream: fan-b leaves
    Stream->>Service: leave event for fan-b
    Service->>Service: whereNow confirms fan-b is gone
    Service->>Alerts: publish moment alert
    Alerts->>Push: push payload
    Push->>Viewer: notification for fan-b only
```

## 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](https://www.pubnub.com/docs/architecture/authentication/set-up-your-account.md) 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](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/real-time-score-alerts.md#before-you-begin) 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:

```bash
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](https://www.pubnub.com/docs/presence/occupancy.md) 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](https://www.pubnub.com/docs/presence/presence-events.md) covers both in full.

```mermaid
flowchart TB
    STREAM["<b>game.stream</b>"]
    PRES["<b>Presence</b><br/>leave event"]
    SVC["<b>Match service</b><br/>confirms the fan<br/>is not watching"]
    ALERT["<b>game.moment-alerts.&lt;userId&gt;</b>"]
    EXT["<b>APNs · FCM</b><br/>a moment worth<br/>coming back for"]
    FAN["<b>That one fan's client</b><br/>left the stream<br/>taps the notification"]
    OTHER["<b>Every other fan</b><br/>still watching<br/>receives nothing"]

    FAN --> STREAM --> PRES --> SVC
    SVC --> ALERT --> EXT --> FAN
    OTHER -.->|"not targeted"| ALERT

    class STREAM,ALERT emphasis
    class PRES muted
    class EXT external
```

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

```javascript
try {
  const response = await pubnub.hereNow({
    channels: ['game.stream'],
    includeUUIDs: false,
  });
  console.log('fans currently watching the stream:', response.totalOccupancy);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Counting the fans watching failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

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

```javascript
async function sendMomentAlert(userId = '') {
  const alertChannel = `game.moment-alerts.${userId}`;
  const moment = PubNub.notificationPayload('Injury time', 'Two minutes left, and it is still 2-2.');

  moment.sound = 'default';
  moment.apns.configurations = [{ targets: [{ topic: 'com.example.matchday' }] }];

  try {
    const response = await pubnub.publish({
      channel: alertChannel,
      message: {
        ...moment.buildPayload(['apns2', 'fcm']),
        moment: 'injury-time',
      },
      customMessageType: 'moment-alert',
    });
    console.log(`moment alert published to ${alertChannel} at timetoken:`, response.timetoken);
  } catch (error) {
    const status = error instanceof Error && 'status' in error ? error.status : undefined;
    console.error(`Publishing the moment alert failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
  }
}
```

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

```javascript
async function notifyIfAbsent(userId = '') {
  try {
    const response = await pubnub.whereNow({ uuid: userId });

    if (response.channels.includes('game.stream')) {
      console.log(`${userId} is still subscribed to game.stream, so no alert is needed`);
      return;
    }
  } catch (error) {
    const status = error instanceof Error && 'status' in error ? error.status : undefined;
    console.error(
      `Checking where ${userId} is subscribed failed: ${error}${status ? ` Additional information: ${status}` : ''}`,
    );
    return;
  }

  console.log(`${userId} is not subscribed to game.stream, so sending a moment alert`);
  await sendMomentAlert(userId);
}
```

Add this to `fan-re-engagement.js`, after [Give every fan their own alert channel](#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

```javascript
const streamSubscription = pubnub.channel('game.stream').subscription({ receivePresenceEvents: true });

streamSubscription.onPresence = (event) => {
  if (event.action === 'leave' || event.action === 'timeout') {
    console.log(`${event.uuid} stopped watching, and ${event.occupancy} fans remain`);
    void notifyIfAbsent(event.uuid);
  }
};

streamSubscription.subscribe();
```

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](https://www.pubnub.com/docs/presence/receive-presence-events.md) 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

```javascript
const viewerUserId = process.argv[2] ?? 'fan-a';

const viewerClient = new PubNub({
  publishKey: 'demo',
  subscribeKey: 'demo',
  userId: viewerUserId,
});

const watchSubscription = viewerClient.channel('game.stream').subscription({ receivePresenceEvents: false });
watchSubscription.subscribe();

try {
  const response = await viewerClient.push.addChannels({
    channels: [`game.moment-alerts.${viewerUserId}`],
    device: 'replace-with-the-fcm-registration-token',
    pushGateway: 'fcm',
  });
  console.log(`${viewerUserId} registered its device for game.moment-alerts.${viewerUserId}:`, response);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Registering the device failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}

const alertSubscription = viewerClient.channel(`game.moment-alerts.${viewerUserId}`).subscription();

alertSubscription.onMessage = (event) => {
  console.log(`${viewerUserId} received a moment alert:`, event.message);
};

alertSubscription.subscribe();

process.on('SIGINT', () => {
  console.log(`${viewerUserId} left game.stream, but is still reachable for a moment alert`);
  watchSubscription.unsubscribe();
});
```

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](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/real-time-score-alerts.md#register-the-device-for-score-alerts) 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:

```bash
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:

```bash
node fan-viewer.js fan-a
```

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

```bash
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**:

```text
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:

```text
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:

```text
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

* [Send a push alert when a goal is scored](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/real-time-score-alerts.md). Register a device for push and publish your first alert.
* [Presence events](https://www.pubnub.com/docs/presence/presence-events.md). The five event subtypes, announce and interval modes, and presence deltas in full.
* [Channel occupancy](https://www.pubnub.com/docs/presence/occupancy.md). What a Here Now response contains and why it stays accurate even in interval mode.
* [Mobile push notifications](https://www.pubnub.com/docs/integrations/mobile-push-notifications/overview.md). The full push delivery model, including why the same event can arrive twice.

Last updated at: 2026-09-30T07:20:08.000Z
