---
source_url: https://www.pubnub.com/docs/use-cases/sports-media-entertainment/live-commentary
title: Deliver play-by-play commentary to every viewer
updated_at: 2026-09-30T07:20:08.000Z
---

# Deliver play-by-play commentary to every viewer

## 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.

PubNub's [Pub/Sub](https://www.pubnub.com/docs/pub-sub/overview.md) model carries a commentator's remarks to every viewer in real time, and [Message Persistence](https://www.pubnub.com/docs/data-storage/message-history/overview.md) holds those remarks so a viewer who arrives late can read what they missed. In this tutorial, you publish timed remarks to one channel and print each remark as a viewer receives it. You then load the recent backlog for a viewer who starts watching after the action began. Commentary only flows one way, from the commentator to viewers, so a viewer's client only ever needs to read that channel, never publish to it.

## What you'll build

`viewer.js` subscribes to `game.commentary`, a channel it only reads. `commentator.js` publishes each remark to that channel with its text and the game clock, and PubNub delivers it to every subscribed viewer at once. Because each remark is also stored in Message Persistence, a viewer that starts late fetches the recent backlog, then follows the channel live.

```mermaid
sequenceDiagram
    participant Commentator as commentator.js
    participant Commentary as game.commentary
    participant Viewer as viewer.js
    participant History as Message Persistence

    Viewer->>Commentary: subscribe, read only
    Commentator->>Commentary: publish remark with text and clock
    Commentary->>Viewer: remark arrives live
    Commentary->>History: remark stored
    Viewer->>History: restarted viewer fetches the last 25 remarks
```

## Before you begin

You need:

* Node.js 22 or later.
* A PubNub account and a 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 yet, follow [Set up your account](https://www.pubnub.com/docs/architecture/authentication/set-up-your-account.md#create-a-keyset) first.
* Message Persistence enabled on that keyset in the [Admin Portal](https://admin.pubnub.com). The catch-up step later on this page reads stored commentary from [Message Persistence](https://www.pubnub.com/docs/data-storage/message-history/overview.md), and without it enabled, that call has nothing to return.

## Set up the project

Create a project and install the PubNub JavaScript SDK:

```bash
mkdir live-commentary-tutorial
cd live-commentary-tutorial
npm init -y
npm install pubnub
```

Open `package.json` and add `"type": "module"` so Node.js treats your files as ES modules. Then create two files: `commentator.js`, the script that publishes remarks, and `viewer.js`, the script that receives them.

Add this to the top of both files, then replace `YOUR_PUBLISH_KEY` and `YOUR_SUBSCRIBE_KEY` with the keys from your keyset:

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

In `viewer.js`, change `userId` to `'fan-42'`. A `userId` identifies one client on PubNub's network, and the commentary feed and a viewer are two different clients.

## Give commentary its own channel

Commentary doesn't share a channel with fan chat. A viewer subscribing to commentary can't publish to it, so a channel dedicated to commentary lets you grant a viewer read-only access with no special casing in your code. The two feeds also carry different traffic: chat is many people publishing to each other, commentary is one publisher and many readers. Keeping them apart also means a viewer who doesn't want commentary can unsubscribe from `game.commentary` alone, without touching chat.

This tutorial uses `game.commentary`, the single channel this feature carries. If you run more than one match at a time, scope the channel per match with a suffix, such as a match ID, rather than reusing `game.commentary` across matches. Channel names cannot exceed 92 UTF-8 characters. So a suffix spends part of that same budget. For the full naming grammar, and what a period in a channel name lets you do, refer to [Channels and channel naming](https://www.pubnub.com/docs/pub-sub/subscribe/channels.md).

```mermaid
flowchart LR
    COMM["<b>Commentator client</b><br/>publish only"]
    CH["<b>game.commentary</b><br/>one publisher<br/>many subscribers"]
    V1["<b>Viewer client</b><br/>subscribe only"]
    V2["<b>Viewer client</b><br/>subscribe only"]
    V3["<b>Viewer client</b><br/>subscribe only"]
    HIST["<b>Message Persistence</b><br/>the calls already made"]
    LATE["<b>Viewer joining late</b><br/>catches up then<br/>follows live"]

    COMM --> CH
    CH --> V1 & V2 & V3
    CH --> HIST
    HIST -->|"1 · replay"| LATE
    CH -->|"2 · live"| LATE

    class CH emphasis
    class HIST muted
```

One commentator publishes to the channel and every viewer only subscribes to it. A viewer who joins late first replays the stored remarks from Message Persistence, then receives new ones live from the channel.

## Publish a remark

Add this to `commentator.js`:

```javascript
try {
  const response = await pubnub.publish({
    channel: 'game.commentary',
    message: {
      text: 'Long ball over the top, and the striker is clean through.',
      clock: '48:07',
    },
    customMessageType: 'commentary',
    storeInHistory: true,
  });
  console.log('commentary published at timetoken:', response.timetoken);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Publishing the commentary failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

The payload carries two fields: `text`, the remark itself, and `clock`, the game clock at the moment the commentator made it, such as `'48:07'`. `clock` is a field in your own payload. PubNub doesn't read it, interpret it, or order anything by it. You carry it because a viewer's own wall clock says nothing about where the match was when the remark was made. Pairing the two lets a viewer place a remark in the match, rather than only in real time.

## Receive commentary

Add this to `viewer.js`:

```javascript
const commentaryChannel = pubnub.channel('game.commentary');
const commentarySubscription = commentaryChannel.subscription({ receivePresenceEvents: false });

commentarySubscription.onMessage = (event) => {
  console.log(`[${event.timetoken}] ${JSON.stringify(event.message)}`);
};

pubnub.addListener({
  status: (event) => {
    if (event.category === 'PNConnectedCategory') {
      console.log('connected and ready to receive commentary');
    }
  },
});

commentarySubscription.subscribe();
```

The subscription is created with `receivePresenceEvents: false`. Commentary is a broadcast feed, and nobody consuming it needs to know who else is listening, so paying for join and leave events here buys you nothing. For what a subscription is and how this option scopes to it, refer to [Subscriptions and subscription sets](https://www.pubnub.com/docs/pub-sub/subscribe/subscriptions.md).

This also registers a status listener that logs `connected and ready to receive commentary` on `PNConnectedCategory`, the status PubNub reports once the subscribe call actually has an open connection. `subscribe()` returns before that connection exists, so it's not itself proof the viewer is ready to receive. In [Run it](#run-it), wait for that log line before you publish anything from `commentator.js`, or the first remark can go out before the viewer is actually listening. For the rest of what a status listener reports, refer to [Monitor and respond to connection status changes](https://www.pubnub.com/docs/architecture/connection-management/monitor-and-respond-to-connection-status-changes.md).

## Identify each remark

PubNub assigns every published message a server-side timetoken: a monotonically increasing 17-digit value precise to 100 nanoseconds (10⁻⁷ s), the number of 100-nanosecond intervals since the Unix epoch. That timetoken is what identifies a remark afterward. In an interface that renders a list of remarks, the timetoken is what you key each row on, because it's the one value guaranteed unique to that remark.

PubNub assigns every message a server-side timetoken when it accepts the publish. The timetoken records when PubNub accepted the message, which can differ from when the client sent it. History fetched through [Message Persistence](https://www.pubnub.com/docs/data-storage/message-history/overview.md) returns a channel's messages in timetoken order.

A subscriber receives live messages in the order they reach it, and each message carries its timetoken. Arrival order can differ from timetoken order and from one subscriber to another. Sorting a channel's messages by timetoken gives every client the same order. Timetoken order applies within a channel, so each channel's messages sort on their own.

Live delivery to subscribers is at-most-once by default. On a stable connection, a subscriber receives each message at most once. After a reconnect, a replayed message can arrive again with the same timetoken. A subscriber can also miss messages if its buffer overflows or if it's disconnected when someone publishes a message.

PubNub does not deduplicate publishes on the server. Publishing the same payload twice always creates two distinct messages with two different timetokens. So if your own code ever calls `publish()` twice for the same remark, for example after retrying a slow acknowledgment, you get two messages with two different timetokens, not one. Comparing timetokens tells you whether two events you received are the same delivery or two separate publishes. It never merges the second one into the first for you.

## Catch up a viewer who joins late

Add this to `viewer.js`, right after the code from [Receive commentary](#receive-commentary):

```javascript
try {
  const response = await pubnub.fetchMessages({
    channels: ['game.commentary'],
    count: 25,
  });

  const entries = response.channels['game.commentary'] ?? [];

  entries.forEach((entry) => {
    console.log(entry.timetoken, entry.message);
  });
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Loading the commentary backlog failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

Subscribe before you fetch the backlog, not after. Subscribing first opens the live feed, so a remark published between your subscribe call and your history fetch still arrives through the live handler. Fetching first and subscribing second leaves exactly that window uncovered, and a remark published during it is gone for this viewer. For the call this step makes, and how to page further back, refer to [Retrieve message history](https://www.pubnub.com/docs/data-storage/message-history/retrieve-message-history.md).

## Stop listening when the viewer leaves

Add this to `viewer.js`. It doesn't run as part of the normal receive path. It runs only when the process is shutting down:

```javascript
process.on('SIGINT', () => {
  console.log('viewer shutting down, closing the commentary subscription');
  commentarySubscription.unsubscribe();
  process.exit(0);
});
```

An abandoned subscription doesn't stop on its own. If a viewer navigates away and your code never calls `unsubscribe()`, the connection stays open and keeps handing every new remark to a handler nobody's using. That spends bandwidth and processing on a screen no one is looking at. In a long-running client, this adds up across every viewer who ever left a page open without closing it.

This tutorial's `viewer.js` is a long-running Node process with no navigation event to hook, so it listens for `SIGINT`, the signal Node sends when you press **Ctrl+C**, and unsubscribes there before exiting. In a browser client, call the same `unsubscribe()` from whatever teardown your framework already runs when a view unmounts or a user navigates away, rather than from a signal handler.

## Run it

Run the viewer first:

```bash
node viewer.js
```

It has nothing to catch up on yet, so it prints nothing until it connects. Wait for:

```text
connected and ready to receive commentary
```

Only once you see that line does the viewer have an open subscription that can receive a live remark. In a second terminal, run the commentator:

```bash
node commentator.js
```

In the viewer's terminal, you should see the timetoken PubNub assigned the remark, followed by the payload:

```text
[17123456789012345] {"text":"Long ball over the top, and the striker is clean through.","clock":"48:07"}
```

Now prove the catch-up path. Stop the viewer with **Ctrl+C**. Its shutdown handler logs `viewer shutting down, closing the commentary subscription` and unsubscribes before the process exits. In `commentator.js`, change the `text` and `clock` values so the second remark is distinguishable, then run the commentator again while no viewer is listening. Restart the viewer, and once it reconnects, it loads the backlog before waiting for the next live remark, so both remarks print in order, oldest first:

```text
17123456789012345 { text: 'Long ball over the top, and the striker is clean through.', clock: '48:07' }
17123456789067890 { text: 'Great save by the keeper.', clock: '51:22' }
```

The backlog and the live path print in different formats because they are two different handlers. The live handler stringifies the payload, and the backlog loop hands the object straight to `console.log`.

## What happened

1. The viewer subscribed to `game.commentary` with presence events turned off, and its status listener logged once that subscription actually connected.
2. The commentator published a remark carrying `text` and `clock`, and PubNub assigned it a timetoken.
3. PubNub delivered the remark to the viewer's active subscription, and the handler printed it.
4. After you restarted the viewer, it fetched stored remarks from Message Persistence before any new remark arrived, so the backlog printed first, in the order the remarks were actually made.
5. Pressing **Ctrl+C** ran the viewer's shutdown handler, which unsubscribed before the process exited, rather than leaving the subscription open until the process happened to end.

## Next steps

* [Send different message types](https://www.pubnub.com/docs/pub-sub/publish/send-different-message-types.md). Label a remark with a custom message type instead of a plain payload.
* [Match stats](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/match-stats.md). Publish data that updates independently, one channel per statistic.
* [Data storage](https://www.pubnub.com/docs/data-storage/overview.md). Retention, filtering, and other ways to work with stored messages.

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