Run a live poll during a match
A live poll needs three things to work end to end. It needs a way to announce that a poll is open, a way for a fan to vote, and a way to publish the tally. PubNub Pub/Sub carries all three as messages on three separate channels.
A poll and its final tally both have to survive being read after the fact, so storing them requires enabling Message Persistence on your keyset. In this tutorial, you write a Node.js script that opens a poll on game.new-poll and a second script that receives the poll and casts a vote on game.poll-votes. Both scripts then watch a tally arrive on game.poll-results.
What you'll build
open-poll.js publishes a poll to game.new-poll and asks PubNub to store it, so a fan who starts late can still fetch it from Message Persistence. vote.js receives that poll, then publishes the fan's vote to game.poll-votes, a channel the fan only writes to. After 20 seconds, open-poll.js stands in for a server and publishes the tally to game.poll-results, and both scripts receive it.
Before you begin
You need:
- Node.js 22 or later.
- A PubNub account and your own keyset. If you don't have one, follow Set up your account to create one.
- Message Persistence enabled on that keyset. Open the Admin Portal, select your keyset, and turn on Message Persistence under its settings.
This tutorial uses three channels: game.new-poll, game.poll-votes, and game.poll-results.
Set up the project
Create a new directory and install the PubNub SDK:
mkdir live-polls-tutorial
cd live-polls-tutorial
npm init -y
npm install pubnub
This tutorial's code uses import, so add "type": "module" to the package.json npm init -y created.
Create two files: open-poll.js, which announces a poll and shows the tally, and vote.js, which receives a poll and casts a vote.
Add this to open-poll.js:
import PubNub from 'pubnub';
const pubnub = new PubNub({
publishKey: 'YOUR_PUBLISH_KEY',
subscribeKey: 'YOUR_SUBSCRIBE_KEY',
userId: 'match-service',
});
Add this to vote.js:
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 in both files with the keys from your keyset. match-service identifies the script that opens polls and tallies votes, and fan-42 identifies the fan casting a vote.
Give each stage of the poll its own channel
A fan only needs to read game.new-poll and game.poll-results. That same fan needs to write to game.poll-votes, but never read it, because reading it would let one fan see how others voted before the tally closes. Splitting the poll's three stages across three channels lets Access Manager express that difference. A single shared channel can't grant read to two of its uses and write to a third.
| Channel | Carries |
|---|---|
game.new-poll | A poll opening |
game.poll-votes | One fan's vote |
game.poll-results | A poll tally |
Only the poll service reads game.poll-votes. It counts the votes and publishes the tally to game.poll-results, so no fan sees a running count before the poll closes.
This tutorial doesn't configure Access Manager, so you can focus on the poll itself. When you do add it, grant read on game.new-poll and game.poll-results and write on game.poll-votes to each fan's token.
Open a poll
Add this to open-poll.js:
1
storeInHistory: true tells PubNub to keep this message in Message Persistence, so a fan who arrives after the poll opened can still retrieve it. customMessageType labels the message poll-opened so a subscriber can branch on the label without inspecting the payload. For more on both parameters, refer to Send different message types.
The standard message payload size limit is 32 KiB. This includes the channel name and any metadata. A poll with a long title or many options can approach that limit, and PubNub rejects a publish that exceeds it.
Show the poll to fans
Add this to vote.js:
1
pubnub.channel() creates a channel entity, and .subscription() scopes a subscription to it that fires onMessage for every poll published on game.new-poll. For the full entity and subscription model, refer to Subscriptions and subscription sets.
Catch fans who arrive after the poll opened
A fan who starts vote.js after the poll already went out never receives the livePollsSubscribeToPolls message, because a subscription only delivers events published after it connects. This is why Message Persistence matters. The poll you stored with storeInHistory: true is still retrievable. Add this to vote.js:
1
fetchMessages with count: 1 retrieves only the most recent stored message on game.new-poll, which is the currently open poll if one exists. For pagination, time ranges, and other retrieval options, refer to Fetch missed messages.
Collect votes
Add this to vote.js, using whichever option ID the fan picked:
1
The vote payload carries no field naming the fan who cast it. PubNub already records the publisher of every message, so adding one would be redundant. On the receiving side, the JavaScript SDK exposes that value as event.publisher.
Count the votes and publish the tally
Counting votes and publishing the result belongs on a server or in a PubNub Function, never in the fan's own client. A fan's client runs code you shipped to them, so any one-vote-per-fan rule you write into that client is a rule the fan can edit out before it runs. Only code that executes somewhere the fan can't change, your server or a Function, can enforce it.
For this tutorial, add the tally to open-poll.js itself, on a delay, so you can watch the whole flow without standing up a separate server:
setTimeout(async () => {
// The results publish goes here.
}, 20000);
Inside that timeout, add the results publish:
1
Add this to both open-poll.js and vote.js, so the poll opener and the fan both see the tally arrive:
1
Add the results subscription to open-poll.js before the setTimeout call, so it's already active when the delayed publish fires.
Run it
Open two terminals in the project directory. In the first, run:
node open-poll.js
You should see:
poll published at timetoken: 17123456789012345
Within 20 seconds, in the second terminal, run:
node vote.js
You should see:
poll that is already open: { id: 'poll-1', title: 'Who will win the match?', durationSeconds: 60, options: [ { id: 1, text: 'Home team' }, { id: 2, text: 'Away team' }, { id: 3, text: 'Draw' } ] }
vote published at timetoken: 17123456789045678
After the 20-second delay, both terminals print the tally:
poll results: { pollId: 'poll-1', totals: { '1': 812, '2': 1043, '3': 219 }, closed: true }
The open-poll.js terminal also prints the line that triggered it:
results published at timetoken: 17123456789098765
Both scripts keep running because their subscriptions hold the connection open. Press Ctrl+C in each terminal to stop them.
What happened
open-poll.jspublished a poll togame.new-polland asked PubNub to store it.vote.jsstarted after that publish, so its live subscription togame.new-pollnever saw the poll directly.vote.jsfetched the stored poll from Message Persistence instead, which is why it saw the poll even though it started late.vote.jspublished a vote togame.poll-votes, a channel it only ever writes to.open-poll.jspublished a tally togame.poll-resultsafter a delay that stood in for a server's counting step.- Both scripts, subscribed to
game.poll-results, received the same tally.
Next steps
- Open a poll automatically when fans react. Trigger this same poll-opening step from a reaction stream instead of a script you run by hand.
- Message Persistence. What gets stored, how retention works, and how timetokens tie storage to live delivery.
- Access Manager. Issue the read and write tokens this tutorial's channel split is designed for.
- PubNub Functions. Run the vote-tallying step at the edge instead of on your own server.