Build a voting website that doesn’t crash under load (in under an hour)

By James Beswick

Spiky traffic? Unpredictable load? Sounds like a job for serverless.

Petition websites can experience the extremes of demand — when a popular motion is set before the public, hundreds of thousands of people can appear from nowhere. Worse yet, many stay after voting, F5-ing their browser to see the new tallies, adding even more load onto the already-smoking servers:

The Tweet above references a major UK news article today showing how this unexpected demand can lead to a self-inflicted denial-of-service. How can we redesign this serverlessly so the site can be up again quickly?

I set myself a challenge this morning to build a solution in an hour! This is what I put together.

I created an empty DynamoDB table with provisioning set to ‘On Demand’. Next, I wrote two functions — VoteYes and VoteNo — which are identical except for which values they increment in the table:

const AWS = require('aws-sdk')
AWS.config.update({ region: process.env.REGION || 'us-east-1' })
const ddb = new AWS.DynamoDB.DocumentClient()
const params = {
TableName : "askJames-pollCounter",
Key: {
partitionKey: 'poll-001',
sortKey: 'total'
},
UpdateExpression: "set votesNo = votesNo + :val",
ExpressionAttributeValues:{
":val": 1
},
ReturnValues:"UPDATED_NEW"
}
exports.handler = async (event) => {
return {
statusCode: 200,
isBase64Encoded: false,
headers: {
"Access-Control-Allow-Origin": "*"
},
body: JSON.stringify(await updateDynamoDB(params)),
}
}
const updateDynamoDB = async (params) => {
return new Promise((resolve, reject) => {
ddb.update(params, function (err, data) {
if (err) {
console.error('updateDynamoDB', err)
reject(err)
} else {
console.log('updateDynamoDB: ', data)
resolve(data)
}
})
})
}

Then I created a getVotes function that returns a summary of the count:

const AWS = require('aws-sdk')
AWS.config.update({ region: process.env.REGION || 'us-east-1' })
const ddb = new AWS.DynamoDB.DocumentClient()
const params = {
TableName : "askJames-pollCounter",
Key: {
partitionKey: 'poll-001',
sortKey: 'total'
}
}
exports.handler = async (event) => {
return {
statusCode: 200,
isBase64Encoded: false,
headers: {
"Access-Control-Allow-Origin": "*"
},
body: JSON.stringify(await updateDynamoDB(params)),
}
}
const updateDynamoDB = async (params) => {
return new Promise((resolve, reject) => {
ddb.get(params, function (err, data) {
if (err) {
console.error('updateDynamoDB', err)
reject(err)
} else {
console.log('updateDynamoDB: ', data)
resolve(data)
}
})
})
}

Finally, I wire these up in API Gateway so they can be called by my front-end:

We are 20 minutes into this exercise — let’s load test these functions. Using Artillery, I simulate 20 users a second hitting the API endpoint for a minute (1.7 million votes per day):

I crank up the values and try again a couple more times with no issues.

The 200 status codes show 100% successful executions in every test. In run this load test on the other function and afterwards check my DynamoDB table:

I set up a vanilla Vue project (vue create poll-counter), add vue-bootstrap and Axios, then construct a simple page with some voting buttons:

Next, I wire up the voting buttons and set a refresh timer so the page will update the voting tally every 5 seconds:

Afterwards, I npm run build and copy the resulting dist folder to an S3 bucket with static website hosting enabled:

Finally, I configure a CloudFront distribution pointing to this bucket, and a custom domain name — about 15 minutes later, we are live.

You can test out this page at https://vote.jbes.dev/ and download the code repo from GitLab at https://gitlab.com/jbesw/askjames-pollcounter-vue.

Serverless is a great fit for this kind of solution. First, the front-end is loaded from an S3 bucket via CloudFront, so withstand a practically-unlimited number of users.

API Gateway scales to hundreds of millions of calls per second and is limited only by your budget. In reality, the soft limits on my AWS account will be the main bottleneck but these can be lifted with a support call.

DynamoDB maintains the state of the voting counts using atomic counters. There is no caching at all in my solution but it could be applied at multiple levels throughout this design to improve performance and reduce cost.

And there it is! One hour, about 200 lines of code — a massively scalable voting platform ready for prime-time.