Deliver play-by-play commentary to every viewer

PubNub's Pub/Sub model carries a commentator's remarks to every viewer in real time, and Message Persistence 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.

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 first.
  • Message Persistence enabled on that keyset in the Admin Portal. The catch-up step later on this page reads stored commentary from Message Persistence, and without it enabled, that call has nothing to return.

Set up the project​

Create a project and install the PubNub JavaScript SDK:

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.

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:

1

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:

1

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.

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

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

1

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.

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:

1

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:

node viewer.js

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

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:

node commentator.js

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

[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:

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. Label a remark with a custom message type instead of a plain payload.
  • Match stats. Publish data that updates independently, one channel per statistic.
  • Data storage. Retention, filtering, and other ways to work with stored messages.

Was this page useful?

Last updated on