Ably engineering

One person, one vote: building live voting with message annotations

One person, one vote: building live voting with message annotations

Live polls are a staple of conferences, streams, and all-hands: a question goes up on the big screen, everyone votes from their phone, and the bars race each other in realtime.

There's a lot of different ways you could implement this.

The most obvious way is a CRUD app backed by a server that votes are POSTed to, and the server keeps a running count. But this is the Ably blog, so we're obviously going to use Ably Pub/Sub to build something which does not need each vote to go via your server. That means much lower latency (votes don't need to go via your single server before being fanned out to other voters) and scalability (if your app goes viral, Ably can cope with a million concurrent voters as easily and with the same low latency as with ten).

Even using Ably there are a lot of ways you could do it. Here I'm building it using Annotations, which are Ably's primitive for attaching information to an existing message; reactions, flags, read receipts, or votes. When clients annotate a message, Ably maintains an aggregated summary of those annotations and delivers it alongside the message.

I'll discuss some other approaches you could take later on (using normal messages, or LiveObjects), and explain why I rejected those in favour of annotations.

How it works

The app has three roles.

  • The voter is the phone in your hand: pick an option (a list, or a d-pad for something more playful), and watch the live percentages. It only ever publishes annotations and reads the summary.
  • The presenter is the big screen. It reads the summary to draw the bar chart or d-pad heatmap (with a star badge on the current leader), and subscribes to individual annotations to pop a bubble for each vote and suggestion as it lands.
  • The admin is the operator console — the only role that publishes poll messages. It walks through a show of polls, opens and closes voting, and (because it can both publish and subscribe) shows the organiser a live view of what's happening.

The admin starts a poll by publishing an ordinary message:

await channel.publish('poll', { pollId, question, type, options });

A voter attaches a vote:unique.v1 annotation to that message's serial, naming the chosen option:

await channel.annotations.publish(pollSerial, {
  type: 'vote:unique.v1',
  name: optionId,
});

Ably aggregates the votes and delivers a summary on the poll message. Voters subscribe and render percentages straight from it — no counting on the client:

channel.subscribe((message) => {
  const summary = message.annotations?.summary?.['vote:unique.v1'];
  // summary[optionId].total is the live vote count for that option
});

The presenter additionally subscribes to the individual events, which is what drives the floating bubbles:

channel.annotations.subscribe('vote:unique.v1', (annotation) => {
  // one event per vote — annotation.name is the option, annotation.clientId the voter
});

That annotation type string, vote:unique.v1, follows the namespace:summarization.version convention; unique is one of five aggregation types (unique, distinct, multiple, total, flag), each rolling up the same raw annotations a different way.

Not every annotation needs a summary. The app's open-ended suggest polls are a counter-example: When a poll asks for free-text suggestions rather than a choice, voters publish a second annotation type — plain suggestion, with no summarization method at all. This results in a one-off line of text that is received by anyone subscribing to annotations (it can drift across the presenter view), and you can use annotations.get() to retrieve a list of annotations for a given message, but it doesn't result in a new message summary being generated.

The channel is attached with rewind: 1, so a phone that joins (or refreshes) mid-poll immediately receives the current poll message and its latest summary. Late joiners are caught up without the admin republishing anything.

Votes are never stored in a database. They live entirely as annotations on the channel, and Ably does the aggregation. The server's job is narrow and cheap: it authenticates clients and it hands out the poll definitions. Anything expensive and latency-sensitive is done by Ably.

The client never holds an API key; it calls the server's /auth endpoint, which mints a short-lived JWT scoped to exactly the role asking for it:


Here's the voter's view: a d-pad of four options, with the current vote highlighted and live percentages:

Voter view

The presenter's "suggest" stage: free-text suggestions floating up as bubbles, each tagged with who sent it:

Presenter view

And the admin "Run Show" console: poll controls on the left, a live tally on the right:

Admin console

Each role is granted only the capabilities it needs by the auth server:

const capsByRole = {
  admin:     ['publish', 'subscribe', 'annotation-subscribe'],
  voter:     ['subscribe', 'annotation-publish'],
  presenter: ['subscribe', 'annotation-subscribe'],
};

Why annotations?

There are a number of architectural approaches you could take. The simplest way is, each vote is a message, the presenter subscribes to the channel and keeps a running tally in memory, and re-renders when needed. This works for a demo, but honestly it's a bit rubbish. Everyone gets the same set of messages, so every voter sees everyone's vote unnecessarily; and nothing constrains one client to only have one vote without custom deduplication logic in any client that wants to count votes, which could get increasingly expensive as the set of voters grows. There's no server-side aggregation, so a phone that joins late, or a presenter view that refreshes, has missed the votes that already happened and has to reconstruct the tally from history. (And imagine if you do have a hundred thousand voters, you'd have to paginate through tens of thousands of pages of history to get the vote count).

LiveObjects is another possibility, which solves some of these problems. You could model the tally as a LiveCounter per option; a synchronised, conflict-free counter on the channel. It's a lovely primitive. But there's no built-in one-vote-per-client constraint: any client can call increment as often as it likes. To stop ballot-stuffing you'd need a separate LiveMap of clientId → vote and check it before incrementing, but there's no atomic check-and-increment across the two, so a double-counting is still possible. And there's no fine-grained capabilities; any client with the ability to increment one livecounter can arbitrarily mutate the whole state.

So, annotations, which have three big advantages:

  • Summaries and raw events are two separate streams, and different clients want different ones. A voter's phone only needs the summary: a single compact, rolled-up object, with aggregated information on all the annotations that have been contributed. The exact information in the summary depends on the annotation's aggregation type, and there are several choices. With very popular polls, each voter only gets a periodically-rolled-up summary update, not one event per vote. But the presenter screen does want to see individual annotation events, so it can animate a bubble for each one (and label it with the voter who cast it), and it can opt into that higher-volume stream separately, with the ANNOTATION_SUBSCRIBE channel mode and an annotations.subscribe() listener. Same underlying data is exposed in two ways, each appropriate to the view that needs it.
  • Capabilities for least-privilege. Because a vote is an annotation, a voter's token can grant only the annotation-publish capability on the channel (and message-subscribe). A voter can cast votes and do literally nothing else: it can't publish poll messages (which are 'full' messages, not annotations), and it can't subscribe to other voters' raw annotations. It can't tamper with the poll or snoop on who voted for what. The presenter gets annotation-subscribe; the admin gets full message publish rights.
  • One person, one vote, enforced by Ably. Votes use the unique aggregation type. In unique mode Ably keeps at most one annotation per clientId, and switching to another option moves that client's vote rather than adding a second one. As long as your auth server vets users and assigns each one a unique clientId, a careful choice of aggregation type gives you the vote semantics you need.

Try it

  • Click around the live voter and presenter example (a somewhat cut-down version that stores the poll options in a static json file to avoid the need for a database for a demo that runs clientside).
  • Clone the full app, and run a show of your own. (Note that annotations need the Message annotations, updates, deletes, and appends channel rule enabled on the namespace, and if you anticipate large numbers of users, we recommend turning on server-side batching for the voting namespace, with a rollup period of say 100ms, to reduce the chance of running into connection inbound rate limits, especially on eg the presenter view which receives every annotation.
  • Read the message annotations docs.

Annotations, appends, and AI Transport

Annotations are one part of a suite of new Ably features that allow interesting ways of referencing and mutating messages. The same infrastructure that allows a new version of a message to be generated with an annotation summary also allows new versions to be generated with updated, deleted, or appended data payloads.

Our new AI Transport product is built on appends (each new token streamed from a model is appended to a response message, so the response is built up over time, so subscribers see it built, but thereafter can treat it as a single message), and I wrote a previous article on some interesting challenges we encountered when engineering appends.