Designing Data-Intensive Applications
Case 5

Ballots, Tallies, and Audits

Append ballots to an immutable log; aggregate counts in stream processors or materialized views.

Diagram

Step-by-step walkthrough

Write path — cast ballot

  • ① POST + Idempotency-Key — Voter submits once; retries return the same ballot ID, not a duplicate vote.
  • ② INSERT ballot — Append-only row in ballot log; ballots are never UPDATEd in place.
  • ③ ballot.cast event — Stream aggregator notified asynchronously to update live totals.

Read path — live totals

  • ④ INCR choice_count — Aggregator increments precomputed counts in Redis on each ballot event.
  • ⑤ GET /results — Results page reads materialized totals from cache — O(1) per choice, not a table scan.
Diagram
typescript — Idempotent ballot submission
// One ballot per voter — idempotent submit
async function castBallot(electionId: string, voterId: string, choiceId: string) {
  return prisma.ballot.upsert({
    where: { electionId_voterId: { electionId, voterId } },
    create: { electionId, voterId, choiceId, castAt: new Date() },
    update: {}, // no-op on retry — same result
  });
}
Slack at scale

Workspace polls are low-stakes but still use one-vote-per-user keys stored in OLTP with unique indexes — the same pattern at national scale with harder identity proofing.

Why these technologies?

Why PostgreSQL (append-only ballot log)?

UNIQUE (election_id, voter_id) enforces one vote per person at the database layer. Append-only inserts preserve audit trail; tallies are derived, never UPDATE-in-place on ballot rows.

Why Idempotency-Key header?

Network retries must not create duplicate ballots — same key returns the same result. Simpler than distributed locks for voter sessions.

Why Kafka / stream processor?

Aggregates millions of ballots into live totals without scanning the full table on every results page refresh. Handles write spikes when polls close.

Why Redis / materialized results cache?

Results pages are read-heavy during election night — precomputed counts serve sub-ms reads. Eventual consistency of +1–2 seconds is usually acceptable for display.

Why API gateway + rate limiting (Cloudflare / WAF)?

Bot protection and per-IP throttling at the edge before ballots hit your origin. Critical when viral links drive traffic spikes.

Why Not updating vote counts in the ballot row?

In-place UPDATE loses auditability and races under concurrency. Event sourcing pattern: insert ballot, derive count.

Key Takeaways
  • Ballot ingest API validates voter eligibility once, writes immutable record.
  • Hash chain or signed events support third-party audit (high-stakes systems).
  • Tally service aggregates by choice_id; real-time preview uses stream processor.
  • Results page reads materialized count — never COUNT(*) on raw ballots live.
  • Idempotency-Key per voter session prevents double submit on retry.
  • Geo-distributed read replicas for results; single-region write primary for consistency.
ballottallyKafkaidempotencymaterialized viewRedispeak QPS