Build a Real-Time Polling Dashboard with Hono and Server-Sent Events
A complete real-time polling app with Hono and Server-Sent Events, including vote deduplication, SSE reconnection, tests, and scaling limits.
Project goal
Build a polling system where users create polls, cast votes, and watch results update live over Server-Sent Events, with honest limits around deduplication and multi-instance fan-out.
Final features
- Create polls with a question and 2–6 options
- Cast a vote through a validated POST endpoint
- Stream live results to every connected dashboard over SSE
- Reject duplicate votes with a poll-scoped voter key
- Reconnect with Last-Event-ID and receive a fresh snapshot
- Render results safely without inserting untrusted HTML
Architecture
A Hono app exposes REST endpoints for poll creation, voting, snapshots, and an SSE stream. A PollService owns validation and vote state. An SSEHub tracks one writer per connection and broadcasts snapshots after each accepted vote. The example is intentionally single-instance; multi-instance fan-out requires a shared pub/sub layer.
Prerequisites
- Node.js 20 LTS or later
- pnpm or npm
- Basic understanding of HTTP streaming and async JavaScript
Dependencies
- hono ^4.6.0 — HTTP framework and streaming helpers
- @hono/node-server ^4.6.0 — Node.js adapter for Hono
- nanoid ^5.0.0 — Poll and option identifiers
Directory structure
polling-dashboard/
├── src/index.ts
├── src/poll-service.ts
├── src/sse-hub.ts
├── src/types.ts
├── public/dashboard.html
├── tests/polls.test.ts
└── package.json
Phases
1. Project setup and types
Create the Hono app, install dependencies, and define shared poll types.
Files: package.json, src/types.ts
```ts
export type Option = { id: string; text: string; votes: number };
export type Poll = { id: string; question: string; options: Option[]; version: number };
```
Expected behavior: The project starts with pnpm dev and exposes a health endpoint.
2. Poll service with validation
Validate questions and options, create polls, and reject duplicate or invalid votes.
Files: src/poll-service.ts
```ts
vote(pollId: string, optionId: string, voterKey: string): VoteResult {
const poll = this.polls.get(pollId);
if (!poll) return { ok: false, error: 'poll not found' };
const option = poll.options.find((item) => item.id === optionId);
if (!option) return { ok: false, error: 'option not found' };
const key = `${pollId}:${voterKey}`;
if (this.voters.has(key)) return { ok: false, error: 'already voted' };
this.voters.set(key, optionId);
option.votes += 1;
poll.version += 1;
return { ok: true, poll };
}
```
Expected behavior: A valid vote increments exactly one option and a repeated vote returns a conflict.
3. SSE hub and stream route
Register one writer per connection, send a snapshot on connect, heartbeat periodically, and remove clients on abort.
Files: src/sse-hub.ts, src/index.ts
```ts
app.get('/api/polls/:id/stream', (c) => {
const pollId = c.req.param('id');
if (!polls.get(pollId)) return c.json({ error: 'not found' }, 404);
return streamSSE(c, async (stream) => {
const client = {
send: (event: string, data: unknown, id?: string) =>
stream.writeSSE({ event, data: JSON.stringify(data), id }),
};
hub.subscribe(pollId, client);
stream.onAbort(() => hub.unsubscribe(pollId, client));
await client.send('snapshot', polls.get(pollId));
while (true) {
await stream.sleep(15_000);
await client.send('ping', { at: Date.now() });
}
});
});
```
Expected behavior: A connected client receives a snapshot, then a vote event after a successful vote, and a ping every 15 seconds.
4. Dashboard client
Render the leaderboard with textContent, open an EventSource, and reconnect using the browser's built-in retry.
Files: public/dashboard.html
```js
const source = new EventSource(`/api/polls/${pollId}/stream`);
source.addEventListener('snapshot', (event) => render(JSON.parse(event.data)));
source.addEventListener('vote', (event) => render(JSON.parse(event.data)));
```
Expected behavior: The dashboard updates without a page refresh and reconnects after a network interruption.
5. Tests for votes and streaming
Test validation, duplicate votes, unknown polls, option ids, and the first SSE chunk.
Files: tests/polls.test.ts
```ts
const created = await app.request('/api/polls', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ question: 'Lunch?', options: ['A', 'B'] }),
});
expect(created.status).toBe(201);
```
Expected behavior: The suite passes without a live network connection.
Testing
Use app.request for REST endpoints and read the first SSE chunk from the stream body. Cover invalid questions, too few or too many options, duplicate votes, unknown polls, and unknown option ids. Test that the version increments only on an accepted vote.
Troubleshooting
EventSource reconnects and the leaderboard resets
The stream sends an initial snapshot on every connection. Re-render from the snapshot instead of appending to existing state.
Votes are counted twice under concurrent requests
The check-and-increment is not atomic. Serialize votes per poll in a single process or use a database transaction with a unique voter constraint.
The SSE connection closes through a proxy after 30 seconds
Send a heartbeat comment or ping event more frequently than the proxy idle timeout, and disable buffering for the stream route.
Deployment
Deploy the single-instance app to Fly.io or Render with SSE-friendly timeouts. For multiple instances, publish vote events through Redis pub/sub or a message broker and keep per-instance subscribers; sticky sessions alone do not deliver events to other instances. Put the app behind a proxy that does not buffer text/event-stream.
Known limitations
- In-memory polls and voter keys disappear on restart and are not shared across instances.
- IP-based voter keys are weak; use an authenticated user id or a signed cookie for real elections.
- A single process has a bounded number of open SSE connections; monitor file descriptors and memory.
Project overview
A polling dashboard is a practical introduction to server push. Users create a poll, cast a vote, and every open dashboard receives the new totals without polling the API. Server-Sent Events fit this one-way update flow: the server keeps a long-lived HTTP response open and writes text frames. The browser’s EventSource reconnects automatically.
The interesting parts are not the happy path. Duplicate votes must be rejected, a reconnecting client must receive a consistent snapshot, proxies must not buffer the stream, and the process must clean up disconnected clients. The first version can be single-instance and in-memory, but the limitations must be stated honestly. This project builds that version, tests it, and marks the boundary to a multi-instance design.
Architecture and data model
Three components keep the code understandable:
PollServiceowns polls, options, vote counts, and voter deduplication. It rejects invalid input before any state changes.SSEHubtracks connected clients by poll id and provides subscribe, unsubscribe, and broadcast. It owns no business logic.index.tsmaps HTTP routes to those components, validates request bodies, and translates results into status codes.
A poll has an id, a question, 2–6 options with stable option ids, a monotonically increasing version, and a creation timestamp. The version is useful for debugging and for clients that want to discard out-of-order snapshots. Votes are stored as counts per option plus a deduplication key per poll and voter. The key is not a vote record; it exists to reject a second vote. If the product needs an audit trail, store individual votes and aggregate them, but that changes the privacy and storage design.
The example uses nanoid for ids. Option ids are generated once and never derived from the option text; changing the text must not change the id that clients send back. Validate that option ids are unique at creation time.
Phase 1: Set up Hono
Create the project and install the runtime dependencies:
mkdir polling-dashboard && cd polling-dashboard
pnpm init
pnpm add hono @hono/node-server nanoid
pnpm add -D typescript tsx vitest @types/node
npx tsc --init
A minimal server mounts the routes and listens on a port from the environment. Use serve from @hono/node-server for Node, and keep the app construction in a function so tests can call app.request without binding a port. Add GET /healthz that returns 200 without touching poll state.
import { serve } from '@hono/node-server';
import { createApp } from './app';
const port = Number(process.env.PORT ?? 3000);
serve({ fetch: createApp().fetch, port });
The createApp function constructs a fresh PollService and SSEHub. A module-level singleton makes tests order-dependent and leaks connections between cases.
Phase 2: Validate creation and voting
Validation belongs at the HTTP boundary. A question must be a non-empty string within a length limit, options must be an array of 2–6 unique non-empty strings, and each option text must be bounded. Reject unknown fields or ignore them deliberately; do not pass the raw body into the service.
type CreatePollInput = { question: string; options: string[] };
function parseCreatePoll(body: unknown): CreatePollInput {
if (typeof body !== 'object' || body === null) throw new HttpError(400, 'invalid body');
const { question, options } = body as Record<string, unknown>;
if (typeof question !== 'string' || question.trim().length < 3 || question.length > 200) {
throw new HttpError(422, 'question must be 3-200 characters');
}
if (!Array.isArray(options) || options.length < 2 || options.length > 6) {
throw new HttpError(422, 'provide 2-6 options');
}
const normalized = options.map((option) => {
if (typeof option !== 'string' || option.trim().length === 0 || option.length > 120) {
throw new HttpError(422, 'each option must be 1-120 characters');
}
return option.trim();
});
if (new Set(normalized).size !== normalized.length) {
throw new HttpError(422, 'options must be unique');
}
return { question: question.trim(), options: normalized };
}
Voting takes a poll id, an option id, and a voter key. The service checks the poll and option before recording the vote. The check-and-increment must be atomic within the process. In a single-threaded Node process, a synchronous method is atomic with respect to other requests. If the method awaits between the check and the increment, two requests can both pass the duplicate check.
vote(pollId: string, optionId: string, voterKey: string): VoteResult {
const poll = this.polls.get(pollId);
if (!poll) return { ok: false, error: 'poll not found' };
const option = poll.options.find((item) => item.id === optionId);
if (!option) return { ok: false, error: 'option not found' };
const key = `${pollId}:${voterKey}`;
if (this.voters.has(key)) return { ok: false, error: 'already voted' };
this.voters.set(key, optionId);
option.votes += 1;
poll.version += 1;
return { ok: true, poll };
}
The voter key is a privacy-sensitive identifier. Hashing an IP address without a secret is reversible by enumeration; hashing with a per-deployment secret is better but still not a strong identity. For a real election, require an authenticated user or a signed, single-use invitation. State this limitation in the UI and the documentation.
Phase 3: Stream with correct SSE semantics
Hono’s streamSSE helper writes correctly framed text/event-stream messages. The route must set no buffering headers, send an initial snapshot, and register cleanup on abort. A heartbeat keeps proxies and load balancers from closing an idle connection; a comment line or a named ping event both work.
import { streamSSE } from 'hono/streaming';
app.get('/api/polls/:id/stream', (c) => {
const pollId = c.req.param('id');
const poll = polls.get(pollId);
if (!poll) return c.json({ error: 'not found' }, 404);
return streamSSE(c, async (stream) => {
const client = {
send: (event: string, data: unknown, id?: string) =>
stream.writeSSE({ event, data: JSON.stringify(data), id }),
};
hub.subscribe(pollId, client);
stream.onAbort(() => hub.unsubscribe(pollId, client));
await client.send('snapshot', poll, String(poll.version));
while (true) {
await stream.sleep(15_000);
await client.send('ping', { at: Date.now() });
}
});
});
The hub keeps a Map<pollId, Set<Client>>. Broadcast iterates a snapshot of the set and removes clients whose write rejects. Do not hold a lock while awaiting network writes. If a client is slow, bound the per-client buffer or disconnect it; an unbounded queue of events turns a slow reader into a memory leak.
class SSEHub {
private clients = new Map<string, Set<Client>>();
subscribe(pollId: string, client: Client) {
if (!this.clients.has(pollId)) this.clients.set(pollId, new Set());
this.clients.get(pollId)!.add(client);
}
unsubscribe(pollId: string, client: Client) {
const set = this.clients.get(pollId);
set?.delete(client);
if (set?.size === 0) this.clients.delete(pollId);
}
async broadcast(pollId: string, event: string, data: unknown, id?: string) {
const set = this.clients.get(pollId);
if (!set) return;
await Promise.allSettled([...set].map(async (client) => {
try {
await client.send(event, data, id);
} catch {
this.unsubscribe(pollId, client);
}
}));
}
}
EventSource reconnects automatically and sends Last-Event-ID when it has seen an id. A full snapshot on every reconnect is the simplest correct behavior. Incremental replay requires an event log and a retention policy; without that, a client that reconnects after missing events cannot reconstruct state from deltas alone.
Phase 4: Build the dashboard safely
The dashboard renders poll results from JSON. Use textContent for question and option text, and create elements with document.createElement. Do not build HTML strings from API data. A poll question is user input and can contain <script> or markup; innerHTML would turn it into an injection point.
function render(poll) {
questionEl.textContent = poll.question;
list.replaceChildren(...poll.options.map((option) => {
const row = document.createElement('li');
const label = document.createElement('span');
label.textContent = option.text;
const count = document.createElement('strong');
count.textContent = String(option.votes);
row.append(label, count);
return row;
}));
}
Open the stream with new EventSource(url), listen for snapshot, vote, and ping, and close it on page unload. Show a connection status badge so users know when updates are paused. The browser reconnects automatically, but the UI should not claim “live” while the stream is down.
Voting should disable the buttons while the request is in flight and then rely on the server’s response. A client-side duplicate check is a UX improvement, not a security control. The server returns 409 for a duplicate and 404 for an unknown poll; display the message and refresh the snapshot.
Phase 5: Tests for the failure paths
Use app.request for REST tests and a stream reader for SSE:
const created = await app.request('/api/polls', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ question: 'Lunch?', options: ['Pizza', 'Salad'] }),
});
expect(created.status).toBe(201);
const poll = await created.json();
const vote = await app.request(`/api/polls/${poll.id}/vote`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ optionId: poll.options[0].id, voterKey: 'test-voter' }),
});
expect(vote.status).toBe(200);
Add cases for an invalid question, fewer than two options, duplicate options, an unknown poll, an unknown option id, and a repeated voter key. For SSE, request the stream, read the first chunk, assert it contains event: snapshot, then cancel the reader and confirm the hub’s client count returns to zero. A test that only checks the response status does not prove streaming works.
Phase 6: Scale and operate
The single-instance design has explicit limits. In-memory polls disappear on restart. Voter keys are not shared across processes. Open SSE connections consume a file descriptor and memory per client. A load balancer may buffer the stream or time out an idle connection. Each limit has a known upgrade path:
- Persist polls and votes in PostgreSQL. Enforce one vote per poll and voter with a unique constraint; do not rely on an in-process map.
- Publish accepted votes to Redis pub/sub or a broker. Every instance subscribes and broadcasts to its local SSE clients. Sticky sessions alone do not solve fan-out.
- Increase heartbeat frequency below the proxy idle timeout, and configure the proxy to disable buffering for the stream route.
- Cap connections per instance and return 503 when the limit is reached. Monitor file descriptors, event-loop lag, and hub size.
- On shutdown, stop accepting connections, close SSE streams with a final event, and wait for in-flight votes.
Instrument votes accepted, duplicates rejected, SSE connections, broadcast latency, and disconnect reasons. A rising duplicate rate may indicate a retry bug or abuse. A rising disconnect rate may indicate a proxy timeout or a slow client. The metrics tell you which layer to fix.
Phase 7: Security and abuse resistance
A poll is a public write endpoint, which makes it an abuse target. The in-memory voter key stops accidental double clicks but not a determined attacker. If the poll decides something valuable, require authentication or a signed invitation, and enforce one vote per identity in the database. If the poll is anonymous, accept that results are advisory and rate-limit by IP, account, and poll.
Never trust a voter key supplied by the client. A caller can generate a new key for every request. Derive the key on the server from an authenticated session or a signed cookie, and bind it to the poll id. Hashing an IP address with a secret reduces direct reversibility but does not prove uniqueness behind a shared NAT. Document the trade-off in the product, not only in the code.
Poll creation needs its own limits. Without a cap, one client can create thousands of polls and exhaust memory. Apply a per-account and per-IP creation quota, cap the question and option lengths, and consider a moderation queue for public polls. The dashboard must render user text with textContent; the same rule applies to the poll creation preview and any admin view.
Vote requests that change state should be protected against cross-site request forgery when authentication uses cookies. Check the Origin header or use a CSRF token, and set cookies with SameSite=Lax or Strict, HttpOnly, and Secure. A JSON content type is not a CSRF defense by itself. Rate-limit the vote endpoint per voter and per poll to blunt scripted traffic, and log rejected attempts with a request id rather than the raw IP.
Phase 8: Persisting polls and votes
The in-memory version is a teaching scaffold. A durable version stores polls, options, and votes in PostgreSQL. The critical constraint is one vote per poll and voter, enforced by the database rather than an application check:
CREATE TABLE votes (
poll_id text NOT NULL,
voter_key text NOT NULL,
option_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (poll_id, voter_key)
);
Insert the vote and increment the option count in one transaction. If the insert conflicts on the primary key, return 409 without incrementing. If the update fails, roll back the insert. The database serializes the conflicting writes, so two concurrent requests cannot both succeed. This is the same idempotency pattern used for webhooks and payment endpoints.
Reads can come from the same database or from a cache. If a dashboard reads a materialized count, update it in the same transaction or derive counts from the votes table on a schedule. Do not maintain a separate counter without a reconciliation job; counters drift after retries and manual fixes. The poll’s version column should increment in the same transaction so SSE clients can detect a snapshot newer than the one they rendered.
Phase 9: Observability and capacity
Track accepted votes, duplicate rejections, invalid option attempts, active SSE connections, broadcast duration, and disconnect reasons. A duplicate spike can mean a retry loop, a double-submitted form, or an attack. A disconnect spike can mean a proxy timeout, a deploy, or clients on unstable networks. Separate these causes in the event names rather than lumping everything into a generic error.
Each SSE connection holds a socket and a small amount of memory. Set a per-instance connection cap and return 503 with Retry-After when it is reached. Monitor file descriptors and event-loop lag; a process that is busy serializing large snapshots can delay heartbeats and cause reconnect storms. Send compact payloads and avoid broadcasting the entire poll history on every vote.
On shutdown, stop accepting new connections, send a final event to open streams, and wait for in-flight votes to commit. The load balancer should stop routing to the instance before the process exits. If the proxy buffers the stream, clients see nothing until the buffer flushes; configure proxy_buffering off or the platform equivalent for the stream route.
Phase 10: Reconnection and proxy behavior
EventSource retries automatically after a dropped connection, but the server must make the retry safe. Sending a full snapshot on every connection is idempotent and simple. If the client sends Last-Event-ID, the server may replay events after that id, but only if it retains an event log. Without a log, ignore the header and send a snapshot; pretending to replay from an empty history is worse than a full refresh.
Proxies and load balancers often have idle timeouts shorter than a user’s attention span. A 15-second heartbeat is a common starting point; measure the actual timeout and send more frequently. Disable response buffering and compression for text/event-stream, and ensure the connection timeout allows long-lived responses. Test through the same proxy used in production, not only against localhost.
Finally, treat the dashboard’s connection state as part of the UX. Show “live”, “reconnecting”, and “offline” states, and disable voting or mark results as potentially stale while disconnected. A silent stream failure that leaves old numbers on screen is worse than an explicit status message. The server and UI should agree on what a snapshot means and when it was produced.
Extensions and limitations
Add poll closing, authenticated creators, option text updates that preserve ids, and an event log for replay. Each feature changes the consistency model. Closing a poll must reject votes atomically with the final count. Editing options after votes exist requires a migration policy. Replay requires storing events and deciding how long a reconnecting client may resume.
The project deliberately does not include a database or authentication. Those are not omissions in the tutorial; they are the boundary where the simple in-memory design stops being honest. The core mechanics—validated input, atomic vote counting, correctly framed SSE, snapshot-on-connect, cleanup, and safe rendering—are the parts that remain when the storage and fan-out layers are replaced.