---
source_url: https://www.pubnub.com/docs/use-cases/sports-media-entertainment/match-stats
title: Publish match statistics that update independently
updated_at: 2026-09-30T07:20:08.000Z
---

# Publish match statistics that update independently

## 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 each statistic change to every viewer as it happens. [Message Persistence](https://www.pubnub.com/docs/data-storage/message-history/overview.md) holds the latest value of each one, so a viewer who arrives mid-match can fill a scoreboard instead of waiting for the next change. This tutorial teaches one design: give each statistic its own channel. That design only works because Message Persistence is enabled on your keyset, so the value of a statistic that hasn't changed in an hour is still there to read.

## What you'll build

`scoreboard.js` subscribes once to the wildcard `game.match-stats.*` and fetches the latest stored value of each statistic from Message Persistence. `statsFeed.js` publishes each change to that statistic's own channel, such as `game.match-stats.score`, and stores it. PubNub delivers the change through the wildcard subscription, and the channel name tells the scoreboard which statistic changed. A scoreboard that starts later reads the current values from Message Persistence in one call.

```mermaid
sequenceDiagram
    participant Board as scoreboard.js
    participant Stats as game.match-stats.*
    participant History as Message Persistence
    participant Feed as statsFeed.js

    Board->>Stats: subscribe once with the wildcard
    Board->>History: fetch the latest value of each statistic
    Feed->>Stats: publish score change to game.match-stats.score
    Stats->>History: change stored
    Stats->>Board: change arrives, channel name says which statistic
```

## 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), with a retention window chosen. Message Persistence retention is 1 or 7 days on the Free plan, 30 days, 3 months, or 6 months on Starter, and 1 year or Unlimited on Pro.

## Set up the project

Create a project and install the PubNub JavaScript SDK:

```bash
mkdir match-stats-tutorial
cd match-stats-tutorial
npm init -y
npm install pubnub
```

Open `package.json` and add `"type": "module"`. Then create two files: `statsFeed.js`, the script that publishes statistic updates, and `scoreboard.js`, the script that receives and displays 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 `scoreboard.js`, change `userId` to `'fan-42'`.

## Give each statistic its own channel

You could publish the whole scoreboard as one message on one channel. Every goal would then republish the possession figure, the shot count, and the cards along with the score, because there's only one message to send. A viewer who renders only the score would still receive, and pay the bandwidth for, every other field in it too. Splitting the scoreboard into one channel per statistic fixes both problems. A change to one number costs one small message, and a client subscribes only to the statistics it actually renders.

| Statistic | Channel |
| --- | --- |
| Score | `game.match-stats.score` |
| Possession | `game.match-stats.possession` |
| Shots | `game.match-stats.shots` |
| Cards | `game.match-stats.cards` |

A layout like this only stays manageable because a client can subscribe to every one of these channels with a single wildcard subscription, `game.match-stats.*`, instead of naming each one. That's the next step. PubNub bounds how many dot-separated levels a wildcard pattern can match. Before you add another level to this hierarchy, such as splitting `score` further, check the current bound in [API limits](https://www.pubnub.com/docs/architecture/limits.md#subscribe). The layout on this page, `game.match-stats.<statistic>` matched by `game.match-stats.*`, fits inside it today.

```mermaid
flowchart TB
    FEED["<b>Stats feed</b><br/>your server"]
    C1["<b>game.match-stats.score</b>"]
    C2["<b>game.match-stats.possession</b>"]
    C3["<b>game.match-stats.shots</b>"]
    WILD["<b>game.match-stats.*</b><br/>one wildcard subscription"]
    HIST["<b>Message Persistence</b><br/>latest value per channel"]
    VIEW["<b>Viewer client</b><br/>one subscribe call<br/>one catch up call"]

    FEED --> C1 & C2 & C3
    C1 & C2 & C3 --> WILD
    WILD --> VIEW
    C1 & C2 & C3 --> HIST
    HIST -->|"on join"| VIEW

    class WILD emphasis
    class HIST muted
```

The stats feed publishes each change to its own channel, and one wildcard subscription delivers all of them to the viewer. Message Persistence keeps the latest value of each channel, so a viewer who joins later fills the scoreboard with a single call.

## Publish one statistic

Add this to `statsFeed.js`:

```javascript
try {
  const response = await pubnub.publish({
    channel: 'game.match-stats.score',
    message: { value: '3-1' },
    customMessageType: 'match-stat',
    storeInHistory: true,
  });
  console.log('score published at timetoken:', response.timetoken);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Publishing the stat failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

`storeInHistory` is what makes this value survive in Message Persistence, so a viewer who arrives after this publish can still read it. `customMessageType`, set here to `match-stat`, labels the publish so a subscriber can branch on the label without inspecting the payload. Refer to [Send different message types](https://www.pubnub.com/docs/pub-sub/publish/send-different-message-types.md) for the full set of publish options this call can take.

## Receive every statistic with one subscription

Add this to `scoreboard.js`:

```javascript
const statsSubscription = pubnub.subscriptionSet({
  channels: ['game.match-stats.*'],
  subscriptionOptions: { receivePresenceEvents: false },
});

statsSubscription.onMessage = (event) => {
  const statName = event.channel.split('.').pop();
  console.log(`${statName} is now`, event.message);
};

statsSubscription.subscribe();
```

`game.match-stats.*` is a wildcard pattern, not a channel you could publish to, and this subscription is still a single `Subscription` object, built the same way any other one is. It receives events from every channel currently matching the pattern, including one that starts carrying traffic after you subscribed. Because one handler now covers four different channels, the channel name on each event, not the payload shape, is what tells you which statistic just changed. Refer to [Subscriptions and subscription sets](https://www.pubnub.com/docs/pub-sub/subscribe/subscriptions.md) for how a subscription like this one relates to a `SubscriptionSet` covering several unrelated entities at once.

## Fill the scoreboard when a viewer arrives

Add this to `scoreboard.js`:

```javascript
const statNames = ['score', 'possession', 'shots', 'cards'];

try {
  const response = await pubnub.fetchMessages({
    channels: statNames.map((statName) => `game.match-stats.${statName}`),
    count: 1,
  });

  statNames.forEach((statName) => {
    const entries = response.channels[`game.match-stats.${statName}`];

    if (entries && entries.length > 0) {
      console.log(`${statName} is currently`, entries[0].message);
    }
  });
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Fetching the current stats failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

Asking for one stored message per channel gives you the current value of every statistic in a single call, the payoff of splitting the scoreboard in the first place. A single-channel scoreboard would need only one such call too, but every field in it would be as stale, or as fresh, as the least recently changed one. That's because they all live in the same message. A single `fetchMessages` call accepts far more channels than this scoreboard uses, so room to grow isn't a concern here. Refer to [Retrieve message history](https://www.pubnub.com/docs/data-storage/message-history/retrieve-message-history.md) for the exact per-call channel ceiling and how to page through more.

## Send only what changed

Publish a statistic when it changes, not on a fixed interval. A timer that republishes every statistic every few seconds sends messages for numbers that haven't moved, which costs bandwidth and processing on both ends for no new information. The only thing a fixed interval buys you is a message that arrives even when nothing happened. Some applications use that message as a liveness signal, confirming the feed is still connected. If you need that signal, send it deliberately and separately from the statistics themselves, rather than letting it dictate how often real changes get published.

## Run it

Run the scoreboard first:

```bash
node scoreboard.js
```

It fetches the current value of every statistic, prints nothing if none have been published yet, and then waits.

In a second terminal, run the stats feed:

```bash
node statsFeed.js
```

The feed's terminal confirms the publish:

```text
score published at timetoken: 17123456789012345
```

The scoreboard's terminal shows the change arriving through the wildcard subscription. The channel name supplied the statistic name, and the payload supplied the value:

```text
score is now { value: '3-1' }
```

Now prove the catch-up path. Stop the scoreboard with **Ctrl+C**. In `statsFeed.js`, change the channel to `game.match-stats.possession` and the value to `'54%'`, then run the feed again while nothing is listening. Restart the scoreboard, and it reads the current value of every statistic from Message Persistence, including the one that changed while it was stopped:

```text
score is currently { value: '3-1' }
possession is currently { value: '54%' }
```

## What happened

1. The scoreboard subscribed once to `game.match-stats.*` and started receiving events from every channel matching that pattern.
2. The stats feed published a change to one statistic, on that statistic's own channel, with `storeInHistory` set.
3. PubNub delivered the change to the scoreboard's wildcard subscription, and the handler used the channel name to update the right field.
4. After you restarted the scoreboard, it called `fetchMessages` once across every statistic channel and filled in the current value of each, including the one that changed while it wasn't running.

## Next steps

* [Live commentary](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/live-commentary.md). Publish a one-way broadcast feed and catch up a viewer who joins late.
* [Send different message types](https://www.pubnub.com/docs/pub-sub/publish/send-different-message-types.md). The full set of options `publish()` takes, including `customMessageType`.
* [API limits](https://www.pubnub.com/docs/architecture/limits.md). Wildcard subscribe depth, message size, and other ceilings this design runs inside.

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