Security and encryption: End-to-end TLS 1.3 for all communication. Votes encrypted at rest using AES-256. Key management through HSM. The system must resist both external attacks and insider threats from database administrators.
Scalability for 100M voters: A national election may have 100 million eligible voters, with the majority voting within an 8-hour window. The system must handle sustained throughput of thousands of votes per second with peak spikes an order of magnitude higher.
99.99% availability during elections: The system cannot go down while polls are open. Downtime during an active election is not just an inconvenience; it disenfranchises voters. This demands redundancy at every layer.
Estimate the scale of the system. Consider daily active users, read/write ratio, storage requirements, bandwidth, and any relevant QPS calculations...
Assume we have 100 million voters, then there are 8 hours of voting window, then per second there will be 2400 votes per second. A single postgres can handle this properly
Assume the peak throughput will be 25000 votes per second
And the read will be the peak to millionnns like 500k per second
Vote records: Each vote record is roughly 500 bytes (vote_id, ballot_id, encrypted selections, timestamp, hash). 70M votes x 500 bytes = 35GB. Even doubling for indexes and overhead, this is under 100GB, comfortably fitting a single PostgreSQL cluster.
Audit logs: Each audit entry is roughly 500 bytes (log_id, action type, timestamp, Merkle hash, metadata). With one entry per vote plus authentication and tabulation events, expect 100M entries = 50GB. Append-only, immutable storage.
Redis cache: Voter eligibility status (has_voted flags) cached for fast deduplication. 100M voters x 100 bytes per entry = 10GB. Fits in a single Redis instance with room for ballot metadata caching.
Inbound: 25K votes/sec x 2KB per request (including headers and TLS overhead) = 50MB/sec at peak. Modest by modern standards.
Outbound: Result reads are the bandwidth driver. 500K reads/sec x 1KB response = 500MB/sec. CDN caching with short TTLs (5-10 seconds) reduces origin traffic by 99%.
Define the APIs expected from the system. This is your chance to analyze and define the read and write paths so that you can come up with the high-level design...
POST /v1/vote
Headers:
Authorization: Bearer <jwt>
Idempotency-Key: <voter_id>:<election_id>
Body: {
election_id: string,
selections: [{ position: string, candidate_id: string }]
}
Response: 202 Accepted {
vote_status: "pending",
confirmation_token: string
}
GET /v1/vote/:voter_id/status?election_id=<election_id>
Headers: Authorization: Bearer <jwt>
Response: 200 OK {
has_voted: boolean,
confirmation_token: string | null,
submitted_at: ISO-8601 | null
}
This endpoint tells a voter whether their vote was recorded. It reveals only the fact of voting, never the ballot content, preserving secrecy even in the API response.
GET /v1/results/:election_id
Response: 200 OK {
election_id: string,
status: "open" | "closed" | "certified",
results: [{ candidate_id: string, name: string, votes: number }],
total_ballots: number,
last_updated: ISO-8601
}
Results are only returned after the election status is "closed" or "certified". Returning results while polls are open would influence remaining voters (the bandwagon effect).
GET /v1/audit/:election_id/logs?page=<n>&limit=<n>
Response: 200 OK {
logs: [{ log_id: string, action: string, timestamp: ISO-8601,
merkle_hash: string }],
merkle_root: string,
pagination: { page: number, total_pages: number }
}
The merkle_root in the response lets auditors verify the integrity of the entire log chain. If any entry was tampered with, recomputing the Merkle root will produce a different hash.
Describe the overall system architecture. Identify the main components needed to solve the problem end-to-end. Use the diagramming tool to create a block diagram.
A voting system sounds straightforward: record a choice and count the results. The real engineering challenge is a contradiction buried in the requirements. You must prove that every voter cast at most one ballot (which requires tracking identity), while simultaneously guaranteeing that nobody, not even the system operator, can discover how any individual voted (which requires destroying the link between identity and ballot). Resolving this contradiction is the central design problem.
Voter authentication with multi-factor verification: Before a voter can cast a ballot, the system verifies their identity through MFA (government-issued ID plus a one-time code). Authentication returns a time-limited JWT that authorizes exactly one vote submission per election. This is the entry gate: no token, no ballot.
One-vote-per-voter enforcement: The system guarantees that each eligible voter casts exactly one ballot per election. Duplicate submissions are rejected, whether caused by accidental double-clicks, network retries, or deliberate manipulation. The constraint is enforced server-side, never trusted to the client.
Result tabulation and audit trail: After polls close, the system aggregates all valid ballots and produces certified results. Every operation, from vote submission to tabulation, is recorded in a tamper-evident audit log that supports post-election verification and dispute resolution.
Ballot and election lifecycle management: Administrators create elections, define ballots with candidates and choices, schedule open/close windows, and publish results. The system supports multiple concurrent elections.
Key Insight
Ballot secrecy is not a nice-to-have feature. In most democracies, it is a constitutional requirement. The system must make it technically impossible to link a voter to their ballot, not just administratively unlikely. This constraint drives the entire database and service architecture.
Security and encryption: End-to-end TLS 1.3 for all communication. Votes encrypted at rest using AES-256. Key management through HSM. The system must resist both external attacks and insider threats from database administrators.
Scalability for 100M voters: A national election may have 100 million eligible voters, with the majority voting within an 8-hour window. The system must handle sustained throughput of thousands of votes per second with peak spikes an order of magnitude higher.
99.99% availability during elections: The system cannot go down while polls are open. Downtime during an active election is not just an inconvenience; it disenfranchises voters. This demands redundancy at every layer.
Paper ballot scanning and optical character recognition, voter registration portals (voters are pre-registered), and campaign management tools. These are adjacent systems that connect via APIs but are designed independently.
The capacity math for a voting system reveals a crucial insight: the average throughput is easy to handle, but the peak spikes at election open and close are what drive the architecture. Designing for the average guarantees failure at the moments that matter most.
100 million eligible voters, 70% turnout: 70 million votes cast over an 8-hour voting window. Average throughput: 70M / (8 x 3,600) = roughly 2,400 votes per second. A single well-provisioned PostgreSQL instance handles this comfortably.
Peak throughput: 25,000 votes per second: The first and last hours of voting see 10x the average rate as voters rush to participate at open and squeeze in before close. This peak, not the average, determines the capacity of every component in the pipeline.
Result reads on election night: Once polls close, millions of citizens check results simultaneously. The read spike can reach 500K requests per second, dwarfing the write load. This is a caching problem, not a database problem.
Interview Tip
The 25K votes/sec peak at election open and close is the real design driver. Every component, from the API Gateway to PostgreSQL connection pools to Redis, must be sized for this peak. If you design for 2,400/sec average, the system collapses precisely when voter participation is highest.
Vote records: Each vote record is roughly 500 bytes (vote_id, ballot_id, encrypted selections, timestamp, hash). 70M votes x 500 bytes = 35GB. Even doubling for indexes and overhead, this is under 100GB, comfortably fitting a single PostgreSQL cluster.
Audit logs: Each audit entry is roughly 500 bytes (log_id, action type, timestamp, Merkle hash, metadata). With one entry per vote plus authentication and tabulation events, expect 100M entries = 50GB. Append-only, immutable storage.
Redis cache: Voter eligibility status (has_voted flags) cached for fast deduplication. 100M voters x 100 bytes per entry = 10GB. Fits in a single Redis instance with room for ballot metadata caching.
Inbound: 25K votes/sec x 2KB per request (including headers and TLS overhead) = 50MB/sec at peak. Modest by modern standards.
Outbound: Result reads are the bandwidth driver. 500K reads/sec x 1KB response = 500MB/sec. CDN caching with short TTLs (5-10 seconds) reduces origin traffic by 99%.
The API surface is deliberately minimal. A voting system has four operations: cast a vote, check vote status, read results, and query audit logs. Keeping the surface small reduces the attack surface, which matters when the system handles a democratic process.
POST /v1/vote
Headers:
Authorization: Bearer <jwt>
Idempotency-Key: <voter_id>:<election_id>
Body: {
election_id: string,
selections: [{ position: string, candidate_id: string }]
}
Response: 202 Accepted {
vote_status: "pending",
confirmation_token: string
}
The 202 Accepted is intentional: the vote is acknowledged but not yet confirmed. The full pipeline (deduplication check, anonymous ballot insertion, audit log write) completes asynchronously. The client polls with the confirmation token to verify the vote was recorded.
The Idempotency-Key uses voter_id:election_id as a natural composite key. If the client retries due to a timeout, the server detects the duplicate key and returns the original result. No synthetic idempotency key is needed because the business constraint (one vote per voter per election) already provides a unique key.
GET /v1/vote/:voter_id/status?election_id=<election_id>
Headers: Authorization: Bearer <jwt>
Response: 200 OK {
has_voted: boolean,
confirmation_token: string | null,
submitted_at: ISO-8601 | null
}
This endpoint tells a voter whether their vote was recorded. It reveals only the fact of voting, never the ballot content, preserving secrecy even in the API response.
GET /v1/results/:election_id
Response: 200 OK {
election_id: string,
status: "open" | "closed" | "certified",
results: [{ candidate_id: string, name: string, votes: number }],
total_ballots: number,
last_updated: ISO-8601
}
Results are only returned after the election status is "closed" or "certified". Returning results while polls are open would influence remaining voters (the bandwagon effect).
GET /v1/audit/:election_id/logs?page=<n>&limit=<n>
Response: 200 OK {
logs: [{ log_id: string, action: string, timestamp: ISO-8601,
merkle_hash: string }],
merkle_root: string,
pagination: { page: number, total_pages: number }
}
The merkle_root in the response lets auditors verify the integrity of the entire log chain. If any entry was tampered with, recomputing the Merkle root will produce a different hash.
The database schema is where the ballot secrecy requirement becomes concrete. The key decision is what you do NOT store: the Votes table has no voter_id column. This is not an oversight; it is the architectural enforcement of ballot secrecy.
Voters Table (PostgreSQL)
voters
├── voter_id UUID PRIMARY KEY
├── name VARCHAR(255)
├── email VARCHAR(255)
├── mfa_secret VARCHAR(255) ENCRYPTED
└── created_at TIMESTAMP
VoterElections Table (tracks who has voted in which election)
voter_elections
├── voter_id UUID ──┐
├── election_id UUID ──┤ COMPOSITE PRIMARY KEY
├── has_voted BOOLEAN DEFAULT FALSE
└── voted_at TIMESTAMP NULL
UNIQUE(voter_id, election_id)
Votes Table (anonymous ballots with no voter_id)
votes
├── vote_id UUID PRIMARY KEY
├── election_id UUID FOREIGN KEY
├── selections JSONB ENCRYPTED
├── ballot_hash VARCHAR(64) - SHA-256
└── created_at TIMESTAMP
Notice what is absent from the Votes table: there is no voter_id, no foreign key to the Voters table, no IP address, no session token. The ballot is cryptographically hashed but structurally anonymous. Even a database administrator with full access cannot determine who cast which ballot.
Common Pitfall
The Votes table must NEVER contain a voter_id column. This is not about access control or encryption. If the column exists, the data link exists, and ballot secrecy is structurally broken regardless of who has read permissions. The schema itself must enforce anonymity.
AuditLog Table (append-only, Merkle chain)
audit_logs
├── log_id UUID PRIMARY KEY
├── election_id UUID FOREIGN KEY
├── action VARCHAR(50) - 'vote_cast', 'election_opened', etc.
├── timestamp TIMESTAMP
├── metadata JSONB
├── entry_hash VARCHAR(64) - SHA-256 of this entry
└── prev_hash VARCHAR(64) - hash of previous entry (Merkle chain)
Each audit entry includes the hash of the previous entry, forming a chain. Modifying any entry changes its hash, which breaks the chain for all subsequent entries and is immediately detectable.
PostgreSQL for vote storage: ACID transactions are non-negotiable. The atomic operation that sets has_voted=true and inserts an anonymous ballot must either fully complete or fully roll back. No eventual consistency, no partial writes. PostgreSQL's transaction guarantees make this possible.
Redis for eligibility caching: The has_voted flag is checked on every vote submission. At 25K votes/sec, hitting PostgreSQL for this check adds 5-10ms latency per vote and 25K additional reads/sec. Redis serves this check in sub-millisecond time from the 10GB in-memory cache.
Why not NoSQL? A document store like MongoDB could handle the write throughput, but it cannot guarantee the atomicity of the cross-table transaction (update VoterElections + insert into Votes) without additional complexity. PostgreSQL provides this natively with a single BEGIN/COMMIT block.
The architecture splits into five services, each with a single responsibility. The separation is not just for clean code; it enforces security boundaries. The Auth Service never touches ballot data, and the Vote Casting Service never touches voter identity details beyond the has_voted flag.
High-level architecture: Client authenticates through API Gateway, Auth Service issues JWT, Vote Casting Service stores anonymous ballots, Tabulation Service aggregates results, and Audit Service maintains Merkle tree integrity.
API Gateway: The single entry point for all client requests. Handles TLS termination, rate limiting (per-IP and per-voter), and request routing. During peak election hours, the gateway enforces admission control by queuing excess requests rather than rejecting them, ensuring every voter eventually gets through.
Auth Service: Handles voter identity verification with MFA. Issues short-lived JWTs (15-minute expiry) scoped to a specific election. The Auth Service has read access to the Voters table but zero access to the Votes table. This service boundary means that even a fully compromised Auth Service cannot reveal ballot content.
Vote Casting Service: The core service. Receives authenticated vote requests, checks the has_voted flag in Redis, acquires a distributed lock, and executes the atomic transaction: set has_voted=true in VoterElections and insert an anonymous ballot into Votes. This is the only service that writes to both tables, and it does so in a single PostgreSQL transaction.
Tabulation Service: Reads from the Votes table and aggregates results. Runs continuously during the election as a streaming aggregation or as periodic batch jobs after polls close. Writes result snapshots to Redis for the results API. This service has read-only access to the Votes table and no access to voter identity data.
Audit Service: Consumes events from Kafka and appends entries to the audit log with Merkle chain hashing. The audit log is append-only; the service has no UPDATE or DELETE permissions on the audit_logs table.
Define the data model. Identify the main entities, their attributes, and relationships. Consider the choice of database type (SQL vs NoSQL) and justify your decision based on access patterns...
The database schema is where the ballot secrecy requirement becomes concrete. The key decision is what you do NOT store: the Votes table has no voter_id column. This is not an oversight; it is the architectural enforcement of ballot secrecy.
VoterElections Table (tracks who has voted in which election)
voter_elections
├── voter_id UUID ──┐
├── election_id UUID ──┤ COMPOSITE PRIMARY KEY
├── has_voted BOOLEAN DEFAULT FALSE
└── voted_at TIMESTAMP NULL
UNIQUE(voter_id, election_id)
Votes Table (anonymous ballots with no voter_id)
votes
├── vote_id UUID PRIMARY KEY
├── election_id UUID FOREIGN KEY
├── selections JSONB ENCRYPTED
├── ballot_hash VARCHAR(64) - SHA-256
└── created_at TIMESTAMP
Notice what is absent from the Votes table: there is no voter_id, no foreign key to the Voters table, no IP address, no session token. The ballot is cryptographically hashed but structurally anonymous. Even a database administrator with full access cannot determine who cast which ballot.
AuditLog Table (append-only, Merkle chain)
audit_logs
├── log_id UUID PRIMARY KEY
├── election_id UUID FOREIGN KEY
├── action VARCHAR(50) - 'vote_cast', 'election_opened', etc.
├── timestamp TIMESTAMP
├── metadata JSONB
├── entry_hash VARCHAR(64) - SHA-256 of this entry
└── prev_hash VARCHAR(64) - hash of previous entry (Merkle chain)
Each audit entry includes the hash of the previous entry, forming a chain. Modifying any entry changes its hash, which breaks the chain for all subsequent entries and is immediately detectable.
Deep dive into 2-3 key components. Explain how they work, how they scale, discuss tradeoffs, capacity, and any relevant algorithms or data structures.
Step 1: Update VoterElections: set has_voted=true for this voter_id and election_id. This records that the voter participated.
Step 2: Insert into Votes: create a new ballot record with a fresh UUID, the election_id, encrypted selections, and a SHA-256 ballot hash. The voter_id is not included.
Both operations happen inside a single BEGIN/COMMIT block. If either fails, both roll back. The voter is never marked as having voted without a ballot being recorded, and a ballot is never recorded without the voter being marked.
The critical insight: after the transaction commits, there is no way to work backwards from a ballot in the Votes table to the voter who cast it. The two tables share an election_id but have no shared voter identifier. This is ballot secrecy enforced by architecture, not by policy.
This section dives into the mechanisms that make the voting system work correctly under concurrent load, network failures, and potential tampering. The central design, separating voter identity from ballot content, touches every component here.
This is the most important design decision in the entire system. When a voter submits their ballot, the Vote Casting Service executes a single PostgreSQL transaction that does two things:
Atomic transaction: the Vote Casting Service updates has_voted in VoterElections and inserts an anonymous ballot into Votes in a single transaction, with no voter_id in the Votes table.
Step 1: Update VoterElections: set has_voted=true for this voter_id and election_id. This records that the voter participated.
Step 2: Insert into Votes: create a new ballot record with a fresh UUID, the election_id, encrypted selections, and a SHA-256 ballot hash. The voter_id is not included.
Both operations happen inside a single BEGIN/COMMIT block. If either fails, both roll back. The voter is never marked as having voted without a ballot being recorded, and a ballot is never recorded without the voter being marked.
The critical insight: after the transaction commits, there is no way to work backwards from a ballot in the Votes table to the voter who cast it. The two tables share an election_id but have no shared voter identifier. This is ballot secrecy enforced by architecture, not by policy.
Merkle tree: each vote hash becomes a leaf node. Parent hashes are recomputed up to the root. Any tampering changes the root hash, making modifications immediately detectable.
Every audit log entry includes a SHA-256 hash of its contents and the hash of the previous entry, forming a chain. Periodically, the Audit Service constructs a Merkle tree from recent entries and publishes the root hash. This published root serves as a cryptographic commitment: if anyone modifies, deletes, or inserts an audit entry, the recomputed Merkle root will differ from the published one.
For post-election verification, auditors download the full log, rebuild the tree, and compare roots. A match proves the log is intact. A mismatch pinpoints which subtree contains the tampered entry, narrowing the search to O(log n) entries.
A voter might click submit twice quickly, or submit from both a phone and a laptop. Without protection, a race condition could allow two concurrent requests to both read has_voted=false and proceed to insert two ballots.
The solution: before executing the atomic transaction, the Vote Casting Service acquires a distributed lock using Redis SETNX with a key of lock:vote:{voter_id}:{election_id} and a TTL of 30 seconds. Only the request that acquires the lock proceeds. The other request waits briefly and retries, at which point has_voted=true and the duplicate is rejected.
The 30-second TTL is a safety net: if the lock holder crashes, the lock auto-expires and the voter can retry. The idempotency key ensures the retry does not create a duplicate ballot.
Peak traffic handling: the API Gateway routes vote requests to a Kafka queue during traffic spikes. Vote processors consume at a controlled rate, and voters receive asynchronous confirmation.
When polls open and 25K votes/sec arrive simultaneously, the Vote Casting Service cannot process them all in real time. A Kafka message queue sits between the API Gateway and the Vote Casting Service during peak windows. Incoming votes are published to Kafka, and vote processor workers consume at a rate the database can sustain.
Voters receive an immediate 202 Accepted with a pending status. Once the vote is processed from the queue, the status updates to confirmed and the voter can verify via the status endpoint. Kafka's guaranteed delivery (replication factor 3, acks=all) ensures no vote is dropped in the queue.
Each ballot record includes a SHA-256 hash of its contents (election_id + selections + timestamp). This hash serves two purposes: it provides a unique fingerprint for the audit trail, and it enables periodic integrity checks. A background job recomputes hashes for stored ballots and compares against the stored values. Any mismatch indicates tampering at rest.
After polls close, the system runs a reconciliation check: the number of has_voted=true records in VoterElections must exactly equal the number of ballot records in Votes for that election. A mismatch indicates either a lost ballot (has_voted=true but no corresponding vote) or a phantom ballot (vote exists without a voter being marked). Either discrepancy triggers an investigation before results are certified.