---
source_url: https://www.pubnub.com/docs/use-cases/sports-media-entertainment/real-time-chat
title: Build match-day chat for fans
updated_at: 2026-09-30T07:20:08.000Z
---

# Build match-day chat for fans

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

This tutorial builds match-day chat for fans on the PubNub JavaScript SDK. It uses [Pub/Sub](https://www.pubnub.com/docs/pub-sub/overview.md) to carry messages, [Presence](https://www.pubnub.com/docs/presence/overview.md) to report who's watching, and [App Context](https://www.pubnub.com/docs/data-storage/metadata/overview.md) to store the channel and fan profiles. It also uses [Message Persistence](https://www.pubnub.com/docs/data-storage/message-history/overview.md) to keep a backlog and [Message Actions](https://www.pubnub.com/docs/pub-sub/message-actions/overview.md) to attach reactions.

By the end, two Node.js clients exchange messages on one channel, `game.chat`. Each client shows how many fans are present and loads the recent backlog on start, and a reaction added on one client appears on the other. Presence, Message Persistence, and App Context must all be enabled on your keyset before you start this tutorial.

## What you'll build

Each of two Node.js clients, `index.js` for `fan-42` and `index2.js` for `fan-108`, first writes the channel's name and its own profile to App Context. It then subscribes to `game.chat` with presence events, so Presence reports who joins and leaves, and fetches the recent backlog from Message Persistence. When one fan publishes a message, PubNub delivers it to the other client. When either fan adds a reaction to a message, PubNub delivers that Message Action to both clients without republishing the message.

```mermaid
sequenceDiagram
    participant A as index.js
    participant Ctx as App Context
    participant Chat as game.chat
    participant History as Message Persistence
    participant B as index2.js

    A->>Ctx: set channel metadata and fan profile
    A->>Chat: subscribe with presence events
    A->>History: fetch recent backlog
    B->>Chat: subscribe with presence events
    Chat->>A: presence join, fan count updates
    B->>Chat: publish message, stored
    Chat->>A: message arrives
    A->>Chat: add reaction as a message action
    Chat->>A: reaction event
    Chat->>B: reaction event
```

## Before you begin

You need:

1. Node.js 22 or later.
2. 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, follow [Set up your account](https://www.pubnub.com/docs/architecture/authentication/set-up-your-account.md) first.
3. Presence enabled on the keyset. Presence must be enabled on the keyset, and new keysets have it enabled by default. Confirm it in the **PRESENCE** section of your keyset settings in the [Admin Portal](https://admin.pubnub.com/).
4. Message Persistence enabled on the keyset. Message Persistence must be enabled on the keyset before you store or read history, and new keysets don't enable it by default. Turn it on in the **MESSAGE PERSISTENCE** section of your keyset settings.
5. App Context enabled on the keyset. App Context must be enabled on the keyset before you call it, and new keysets don't enable it by default. Turn it on in the **APP CONTEXT** section of your keyset settings.

You also need the publish key and subscribe key from that keyset.

## Set up the project

Create a new directory and install the PubNub JavaScript SDK:

```bash
mkdir match-day-chat
cd match-day-chat
npm init -y
npm install pubnub
```

Add `"type": "module"` to `package.json`, since the code on this page uses `import`. Create a file called `index.js`, then add the client:

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

Replace `YOUR_PUBLISH_KEY` and `YOUR_SUBSCRIBE_KEY` with the keys from your keyset. This file represents one fan, `fan-42`. Later, once you reach [Give the fan a profile](#give-the-fan-a-profile), you'll duplicate it to get a second fan for the other terminal.

## Describe the chat channel

Give `game.chat` a name and description in App Context, so every client can render the same header without maintaining its own copy of it.

```javascript
try {
  const response = await pubnub.objects.setChannelMetadata({
    channel: 'game.chat',
    data: {
      name: 'Match chat',
      description: 'Open chat for everyone watching the match',
    },
  });
  console.log('channel metadata set:', response.data);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Setting the channel metadata failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

Append this call to `index.js`. [App Context](https://www.pubnub.com/docs/data-storage/metadata/manage-channel-metadata.md) is where a channel's `name` and `description` live, so any client that reads `game.chat`'s metadata, not only the one that set it, renders the same "Match chat" header. Calling this again later overwrites the same fields, so both fans running it on startup is harmless. Without App Context, every client would need its own hardcoded copy of the channel's name, which drifts the moment one copy changes and another doesn't.

## Give the fan a profile

Give the connecting fan a name and an avatar in App Context, so the other fan can render it next to their messages.

```javascript
try {
  const response = await pubnub.objects.setUUIDMetadata({
    uuid: 'fan-42',
    data: {
      name: 'Alex Moreau',
      profileUrl: 'https://example.com/avatars/fan-42.png',
      custom: { supports: 'home' },
    },
  });
  console.log('fan profile set:', response.data);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Setting the fan profile failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

This calls [Set user metadata](https://www.pubnub.com/docs/data-storage/metadata/manage-user-metadata.md) for `fan-42`, the same User ID this file's client uses. To get a second fan for the other terminal, copy `index.js` to `index2.js`, then in the copy change `userId: 'fan-42'` in the client configuration and `uuid: 'fan-42'` in this call to `fan-108`. In this tutorial, each fan sets its own profile on startup. In production, a client shouldn't be able to write another fan's profile, since App Context has no owner check of its own. [Access Manager's permission model](https://www.pubnub.com/docs/security/access-control/permission-model.md) enforces that requirement, granting the `update` permission on a User ID only to the client authorized to hold it.

## Join the chat

Subscribe to `game.chat` with presence events turned on, and register a handler for messages and one for presence.

```javascript
const chatChannel = pubnub.channel('game.chat');
const chatSubscription = chatChannel.subscription({ receivePresenceEvents: true });

chatSubscription.onMessage = (event) => {
  console.log(`${event.publisher}: ${JSON.stringify(event.message)}`);
};

chatSubscription.onPresence = (event) => {
  if (event.action === 'interval' || event.action === 'join' || event.action === 'leave') {
    console.log('fans in this chat:', event.occupancy);
  }
};

chatSubscription.subscribe();
```

`receivePresenceEvents` asks this subscription for [presence events](https://www.pubnub.com/docs/presence/receive-presence-events.md) as well as messages. The message handler runs each time the other fan publishes to `game.chat`. The presence handler runs on join, leave, and the periodic interval update, printing the current fan count each time, so the count you show later stays current without polling. Leaving `receivePresenceEvents` out still delivers messages normally. It leaves the presence handler silent, with no error to flag the difference.

## Send a message

Publish a chat message to `game.chat`.

```javascript
try {
  const response = await pubnub.publish({
    channel: 'game.chat',
    message: { text: 'What a save!' },
    customMessageType: 'text-message',
    storeInHistory: true,
  });
  console.log('chat message published at timetoken:', response.timetoken);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Publishing the chat message failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

The standard message payload size limit is 32 KiB. This includes the channel name and any metadata. A message over that limit fails the publish call instead of arriving trimmed, so keep a chat message to plain text rather than an embedded image or a large payload. `storeInHistory: true` makes this message available to the history call in the next step. The timetoken this call returns identifies the message uniquely, which matters again in [Let fans react to a message](#let-fans-react-to-a-message).

## Load what was said before the fan arrived

Fetch the recent backlog on startup, so a fan who joins mid-match sees what was already said.

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

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

  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 recent messages failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

This calls [Retrieve message history](https://www.pubnub.com/docs/data-storage/message-history/retrieve-message-history.md) for `game.chat`. 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, so how far back this call can reach depends on your keyset's configured retention, not on this code. Call it once, right after subscribing, rather than on every render, since the messages it returns don't change until someone publishes a new one.

## Show how many fans are in the chat

Call Here Now once, right after subscribing, to seed the fan count you'll then keep current from presence events.

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

This calls [Here Now](https://www.pubnub.com/docs/presence/occupancy.md) for `game.chat` in count-only mode, `includeUUIDs: false`. That mode keeps the response flat whether the chat has 3 fans or 3,000, since a count is all this step needs.

Call it once on join rather than on a timer. Each `Here Now` call has a cost, and a tight polling loop pays for each one without adding live accuracy — the presence handler you registered in [Join the chat](#join-the-chat) keeps the count current after that first call, at no extra cost per update.

## Let fans react to a message

Add a reaction to a message, then handle the reaction event on both clients.

```javascript
try {
  const response = await pubnub.addMessageAction({
    channel: 'game.chat',
    messageTimetoken: 'replace-with-message-timetoken',
    action: {
      type: 'reaction',
      value: '\u{1F44F}',
    },
  });
  console.log('reaction added at timetoken:', response.data.actionTimetoken);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Adding the reaction failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

```javascript
const reactionsSubscription = pubnub.channel('game.chat').subscription();

reactionsSubscription.onMessageAction = (event) => {
  console.log(`${event.publisher} reacted with ${event.data.value} to the message at ${event.data.messageTimetoken}`);
};

reactionsSubscription.subscribe();
```

The first call attaches a [message action](https://www.pubnub.com/docs/pub-sub/message-actions/overview.md) to a message that already exists, identified by that message's own timetoken. Replace `replace-with-message-timetoken` with a real one, for example the timetoken your terminal printed in [Send a message](#send-a-message). PubNub stores the reaction against that original message. So reacting to it needs no second channel and no bookkeeping table of your own to associate a reaction with the message it belongs to. The second block registers the handler that fires when either fan adds a reaction, so both clients show it without either one re-publishing the original message.

## Run it

Open two terminals in `match-day-chat`. In the first, run:

```bash
node index.js
```

In the second, run the duplicated file for the other fan:

```bash
node index2.js
```

Each terminal logs the channel metadata it set, its own fan profile, an initial fan count, the recent backlog, and the message it published. Once both are running, each terminal also prints the message the other fan sent. Output resembles this, with your own timetokens:

```text
channel metadata set: { name: 'Match chat', description: 'Open chat for everyone watching the match' }
fan profile set: { name: 'Alex Moreau', profileUrl: 'https://example.com/avatars/fan-42.png', custom: { supports: 'home' } }
fans in the chat: 1
17000000000000000 { text: 'What a save!' }
chat message published at timetoken: 17000000000000000
fans in this chat: 2
fan-108: {"text":"What a save!"}
```

The reaction call fails on this first run, because `replace-with-message-timetoken` isn't a real message. Copy a timetoken your terminal printed, paste it into that call, and run `node index.js` again. This time both terminals print the reaction. Press **Ctrl+C** in either terminal to stop that client.

## What happened

1. Each client set `game.chat`'s name and description in App Context, and its own fan profile.
2. Each client subscribed to `game.chat` with presence events enabled, then called Here Now once to seed its fan count.
3. Each client fetched the recent backlog from Message Persistence before reacting to anything new.
4. Each client published a chat message, which PubNub delivered to the other client's message handler.
5. One client added a message action to a message, and PubNub delivered that action to both clients' message-action handlers, without a second channel or a republish of the original message.

## Next steps

* [Hide and remove chat messages during a match](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/game-chat-moderation.md). Flag a message with a Message Action and delete it from history for good.
* [Mute and ban a disruptive fan](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/fan-behavior-management.md). Control what a fan's token allows with Access Manager.
* [Presence](https://www.pubnub.com/docs/presence/overview.md). How to enable presence data, and what it delivers beyond the count this tutorial showed.
* [App Context](https://www.pubnub.com/docs/data-storage/metadata/overview.md). The full metadata model behind the channel and profile calls on this page.

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