We built a serverless real time leaderboard for AWS Community Day and gave away an iPad
How we built an interactive quiz leaderboard for AWS Community Day, and the serverless architecture behind it.

Before this year's second edition of AWS Community Day, we sat down to figure out what to build for the crowd. We landed on an interactive app: quizzes and scores, nothing too fancy. Here's how it actually turned out.
The initial concept
Picture around 200 cloud people in one room at AWS Community Day, all there for the latest cloud news. We'd been part of this event before, and this time we wanted to bring something of our own. Something fun, with a bit of a competitive edge.
So why not run a tech quiz? One that tests cloud knowledge and turns up the heat with every round.
And the prize? A new iPad for whoever won.
We started with a plain technical challenge in mind. The core idea:
- Multiple quiz rounds, all on technical topics
- After each round, the bottom 50% of participants get eliminated
- Notifications to participants about their score
Starting simple: from Google Forms to a custom solution
The Google Forms approach
Why reinvent the wheel? Use the tools you already have. So I suggested Google Forms. No form system to build, response collection comes built in through Google Sheets, and it's quick to set up. Nice try, though.
A couple of hours later we'd already hit the walls:
- We wanted to customize forms per participant (with their name and info)
- We needed scoring logic based on both accuracy and response time
- We had to slice the data in ways that Google Forms made awkward
- Wiring Google Forms into our notification system was possible, but no thanks
Time to look elsewhere.
The Typeform approach
We found a tidy solution here: plenty of integrations, good-looking forms, lots of customization. Maybe this was it. We threw together a dummy form to see where the answers could go.
Docs, Sheets, Excel, MySQL. Yeah, this could work. Let's stand up a DB then, I thought. Except, doesn't that quietly break the "simple and nothing fancy" promise?
Fine. Then we build our own.
Execution flow
1. Participant scans a QR code
2. Fills registration form with phone number, name, email
3. Forms are created for each challenge, uploaded to S3, and pre-signed URLs are generated for retrieval
4. Data is stored in Aurora
5. Admin starts a challenge
6. A pre-signed URL is fetched from Aurora and sent to the Shorten Step
7. Using the participant data and short URL, the send-sms method sends out an SMS notification
8. Participant receives SMS, opens the short URL and views the form
9. When the HTML form is first opened, a timestamp is captured in JavaScript to track response time
10. A POST request delivers all the information to the FastAPI endpoint
11. The application pulls live Aurora data for real-time display
Building a custom form solution
Once that decision was made, the brakes came off. There were plenty of ways to wire these services together, shape the data, build the forms. All we needed was JUST a backend with a few API endpoints, a database, a notification system and some HTML forms. I'll walk through why and how we picked each tool. Let's start at the beginning: the HTML forms.
What's the cheapest way to host a few simple forms?
The S3 form architecture
1. HTML templates
These templates had placeholders that got swapped for personalized info like the participant's name, email, and unique identifier.
<!-- templates/challenge1_template.html -->
<div class="welcome-message">
<h2>Welcome, {{name}}!</h2>
<p>This is your personalized AWS quiz. Answer the questions to get points and climb the leaderboard!</p>
<p><strong>Your email:</strong> {{email}}</p>
</div>
Custom HTML Templates
2. Personalized form generation
When a participant registered, we generated their forms for every challenge in one go. Each form was tailored to that person, with their info baked straight into the HTML.
async def generate_all_challenge_forms(user_id, email, name):
"""
Generate personalized forms for all challenges for a user.
"""
try:
form_urls = {}
# Generate forms for all challenges
for challenge_num in CHALLENGES.keys():
form_url = await generate_personalized_form(user_id, email, name, challenge_num, skip_db_store=True)
if form_url:
form_urls[challenge_num] = form_url
else:
logger.error(f"Failed to generate form for challenge {challenge_num}, user {user_id}")
# Store all form URLs in database
success = database.store_multiple_form_links(user_id, form_urls)
logger.info(f"Generated {len(form_urls)} forms for user {user_id}")
return form_urls
except Exception as e:
logger.error(f"Error generating all challenge forms: {str(e)}")
return None
Form Generation Function in FastAPI
3. S3 storage strategy and lifecycle management
We went with a deliberately old-school S3 storage layout that sorted forms into directories by status.
That made it easy to track who'd participated and to block late submissions. And the lifecycle? Dead simple. When a participant submitted their response, we just moved the object from forms to answered.
Nobody had to log in, we kept track of everyone's responses, and double submissions were off the table. The unanswered directory was for forms that never came back. So if a challenge ended with some forms still open, those got moved to unanswered automatically and that user dropped out of the remaining challenges.
S3_BUCKET/
├── forms/ # Active, unsubmitted forms
│ ├── challenge1/
│ ├── challenge2/
│ └── challenge3/
├── answered/ # Completed forms
│ ├── challenge1/
│ ├── challenge2/
│ └── challenge3/
└── unanswered/ # Forms not submitted when challenge ended
├── challenge1/
├── challenge2/
└── challenge3/
S3 Structure for Storing Forms
4. Pre-signed URLs for secure access
A key part of the design was using pre-signed URLs to hand out temporary access to the forms.
This bought us a few things, and one nasty surprise. Keep reading.
- Security – forms weren't publicly accessible
- Limited access – URLs expired after 24 hours; well, technically
- Simplified authentication – participants didn't need to log in
- Participant tracking – each URL was unique and mapped to a participant/challenge combination
# Generate pre-signed URL that expires after 24 hours (86400 seconds)
form_url = s3_client.generate_presigned_url(
'get_object',
Params={
'Bucket': S3_BUCKET_NAME,
'Key': s3_path,
'ResponseContentDisposition': 'inline',
'ResponseContentType': 'text/html'
},
ExpiresIn=86400,
HttpMethod='GET'
)
S3 Pre-Signed URL Generation Function in FastAPI
5. URL shortening for form links
The pre-signed S3 URLs were long enough to be useless over SMS. So we added a shortening step.
async def shorten_form_url(url_long):
"""
Shortens a form URL using the CloudFront endpoint.
"""
try:
shortener_endpoint = "https://awsfn.com/admin_shrink_url"
payload = {
"url_long": url_long,
"cdn_prefix": "awsfn.com"
}
async with aiohttp.ClientSession() as session:
async with session.post(shortener_endpoint, json=payload) as response:
if response.status == 200:
result = await response.json()
if "url_short" in result:
return result["url_short"]
# Fallback to original URL if shortening fails
return url_long
except Exception as e:
logger.error(f"Error shortening URL: {e}")
return url_long
Shorten URL Function in FastAPI
This turned unwieldy URLs like:
https://aws-community-day-forms.s3.amazonaws.com/forms/challenge1/user_42.html?AWSAccessKeyId=AKIAZQ3DQOKKHUNUMYDJ&Signature=6iPKUAqbywMdlGQR0cZ1bQQgnDc%3D&Expires=1742288401
Into short links like:
https://awsfn.com/pszttib
This AWS blog post and its CloudFormation stack saved us a few hours of implementation.
We deployed the stack and that was it. Our own private URL shortener.
Optimization challenges
1. Bulk form generation
Generating forms one at a time, per participant and per challenge, would have been too slow. So we generated all of a user's forms at registration.
That pushed the heavy work to registration time, when load was light, instead of the moment a challenge started and everyone needed a form at once.
@app.post("/api/register")
async def register_user(
name: str = Form(...),
email: str = Form(...),
phone: str = Form(...),
company: str = Form(None)
):
# Register user in database
# ...
# Generate ALL forms for ALL challenges at registration time
form_urls = await generate_all_challenge_forms(user_id, email, name)
# Return success response with the first form URL
return {
"success": True,
"message": "Registration successful!",
"user_id": user_id,
"form_urls": form_urls,
"current_form_url": form_urls.get("1", "")
}
API Endpoint for user registration and Form Generation function in FastAPI
2. Database storage of form URLs
To avoid regenerating forms, we stored every form URL in the database.
That let us pull a form URL the moment we needed it, like when an SMS went out.
def store_multiple_form_links(user_id, form_urls):
"""
Store multiple form links for a user in the database.
"""
try:
conn = get_db_connection()
cursor = conn.cursor()
# Use a transaction for all inserts
cursor.execute("START TRANSACTION")
for challenge_num, form_url in form_urls.items():
cursor.execute(
"""
INSERT INTO form_links (user_id, challenge_num, form_url, created_at)
VALUES (%s, %s, %s, %s)
""",
(user_id, challenge_num, form_url, current_timestamp)
)
conn.commit()
logger.info(f"Stored {len(form_urls)} form links for user {user_id}")
return True
except Exception as e:
logger.error(f"Error storing form links: {e}")
if conn:
conn.rollback()
return False
finally:
if conn:
conn.close()
Function to store Pre-Signed URLs in the database in FastAPI
Outcome and benefits
The custom S3-based form system paid off in a few ways:
-
Complete control and personalization – we could shape every bit of how the form looked and behaved
-
Response time tracking – we could measure how fast each participant finished a challenge
-
Lifecycle management – forms moved through a clear path from active to answered or unanswered
-
Scalability – the system handled hundreds of concurrent participants without breaking a sweat
-
Integration – forms submitted straight to our API, so we could do whatever we wanted with the data
It took more upfront work than Google Forms would have. The flexibility and control were what we were after, so that trade was worth it.
FastAPI: shaping the backend
With the S3-based form system in place, we needed a backend that could take submissions, hold the application state, and push real-time updates. And yes, real-time: once we started building our own thing, a live-updating leaderboard sounded a lot cooler than a static one.
FastAPI fit the job. Here's why:
1. Asynchronous support
FastAPI's native async handling mattered for taking concurrent submissions during a challenge, when dozens or hundreds of people might hit submit at the same moment.
That was the part that kept the leaderboard responsive while it updated in real time.
@app.post("/api/submit/{challenge_num}/{user_id}")
async def submit_challenge(challenge_num, user_id, score, answers):
# Process submission asynchronously
Async function in FastAPI
2. Built-in WebSocket support
The real-time leaderboard was the heart of the app, and FastAPI's native WebSocket support made it easy to wire up.
We could push leaderboard updates to every connected client the moment a new submission landed, which is what gave the room its energy.
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
# Send current leaderboard immediately
# Then keep connection open for updates
Web-Socket Endpoint in FastAPI
3. Background task processing
For the slow stuff like firing SMS notifications to hundreds of people, FastAPI's background tasks earned their keep.
They kept API requests from timing out during long operations, and they made our own admin life easier.
@app.post("/api/admin/start-challenge/{challenge_num}")
async def start_challenge(challenge_num, background_tasks: BackgroundTasks):
# Start sending SMS notifications in the background
background_tasks.add_task(
send_notifications_background,
qualified_users=qualified_users,
challenge_num=challenge_num
)
# Return immediately with success message
return {"success": True, "message": "Challenge started!"}
Task sent for processing in the background in FastAPI
Key API features
1. Submission processing
The form submission endpoint had to:
- Validate incoming data and calculate response times (between form view and submission)
- Store answers and scores in the database and update the leaderboard
- Move forms from active to answered in S3
2. Challenge management
For us, the event admins, we built endpoints to drive the challenge flow. They ran the whole event lifecycle: starting challenges, processing eliminations, declaring winners.
# Start a challenge and send notifications
@app.post("/api/admin/start-challenge/{challenge_num}")
# End a challenge and process eliminations
@app.post("/api/admin/end-challenge/{challenge_num}")
API Endpoints for challenge controls in FastAPI
3. Real-time progress tracking
For long jobs like sending SMS notifications, we added progress tracking.
That let us show real-time progress bars, so we could actually see what the background work was doing.
@app.get("/api/admin/sms-progress/{challenge_num}")
async def get_sms_progress_api(challenge_num: str):
progress = get_sms_progress(challenge_num)
return {
"status": progress["status"],
"total": progress["total"],
"sent": progress["sent"],
"failed": progress["failed"],
"progress_percent": progress["progress_percent"]
}
Progress Tracking function in FastAPI
Challenges and solutions
For long jobs like sending SMS notifications, we added progress tracking.
That let us show real-time progress bars, so we could see what the background work was doing.
1. Elimination logic
After each challenge, we had to cut the bottom 50%. That meant some fuzzy math:
- Sorting participants by score and response time
- Picking out the bottom half to eliminate
- Updating their status in the database
- Sending elimination notifications
We wrote a sorting routine that weighed score accuracy first and used response time as the tiebreaker.
2. Form lifecycle management
Moving forms between S3 directories meant the API and the storage operations had to stay in step.
That coordination is what stopped late submissions once a challenge had closed.
# When a form is submitted
s3_client.copy_object(
Bucket=S3_BUCKET_NAME,
CopySource={'Bucket': S3_BUCKET_NAME, 'Key': source_key},
Key=f"answered/challenge{challenge_num}/user_{user_id}.html"
)
s3_client.delete_object(Bucket=S3_BUCKET_NAME, Key=source_key)
# When a challenge ends, move all remaining forms
for form in forms:
# Move from forms/ to unanswered/
Function for copying objects from a S3 directory to another in FastAPI
3. The admin dashboard
The admin dashboard gave us live data stats, challenge controls, notification status, the list of eliminated participants, and the winner-announcement controls.
The solution so far
The S3-based form system plus the FastAPI backend gave us one integrated platform, and it turned a simple quiz into a live, competitive event.
Aurora Serverless: powering the database layer
S3 hosted the forms and FastAPI ran the backend logic. Aurora was the database underneath that held it all together. Here's why we reached for this managed service.
The app needed a database that stayed reliable and scaled through the spikes when everyone submitted at once. Aurora MySQL Serverless covered that.
1. Managed service benefits
As a DevOps-leaning team, we liked that Aurora was fully managed. The things we didn't have to babysit:
- Scaling for peak traffic
- Database server provisioning
- Backups and recovery
- High availability configuration
That freed us to spend our time on the app instead of on database admin.
2. MySQL compatibility
Aurora speaks MySQL, so we kept the tools and libraries we already knew. The team was comfortable with MySQL, which made Aurora the obvious pick. We used the PyMySQL library to talk to it.
Database schema evolution
The schema grew as the app's needs grew.
We kept users and participants as separate tables. The users table held the full registration record, while the leaner participants table was what we queried for the leaderboard, which kept those reads fast.
Here's how a couple of the key tables looked.
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
phone VARCHAR(50) NOT NULL,
company VARCHAR(255),
registered_at DATETIME NOT NULL
);
CREATE TABLE participants (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL
);
Part of the FastAPI database initialization script
Key database operations
1. Form submission processing
When a participant submitted answers, we had to record their score and update the leaderboard:
# Calculate response time
cursor.execute(
"SELECT first_viewed_at FROM form_views WHERE user_id = %s AND challenge_num = %s",
(user_id, challenge_num)
)
view_data = cursor.fetchone()
response_time_seconds = (submit_time - view_data["first_viewed_at"]).total_seconds()
# Save answers and score
cursor.execute(
"""
INSERT INTO user_answers (user_id, challenge_number, answers, score, submitted_at)
VALUES (%s, %s, %s, %s, %s)
""",
(user_id, int(challenge_num), answers, score, datetime.now())
)
# Update challenge score with response time
cursor.execute(
"""
REPLACE INTO challenge_scores
(participant_id, challenge_number, score, timestamp, response_time_seconds)
VALUES (%s, %s, %s, %s, %s)
""",
(participant_id, int(challenge_num), score, datetime.now(), response_time_seconds)
)
Various queries executed sequentially once an answer was submitted
2. Leaderboard generation
Building the leaderboard meant joining a handful of tables and doing some non-trivial sorting:
def get_db_leaderboard_for_challenge(challenge_num):
"""Get leaderboard data for a specific challenge."""
try:
# Get elimination data
cursor.execute("""
SELECT p.email, e.challenge_number
FROM eliminated e
JOIN participants p ON e.participant_id = p.id
""")
eliminated_map = {row["email"]: row["challenge_number"] for row in cursor.fetchall()}
# Get participants for this challenge
cursor.execute("""
SELECT
p.id AS participant_id,
p.email,
u.name,
cs.score AS challenge_score,
cs.timestamp,
cs.response_time_seconds
FROM
participants p
JOIN
users u ON p.email = u.email
LEFT JOIN
challenge_scores cs ON p.id = cs.participant_id AND cs.challenge_number = %s
""", [int(challenge_num)])
# Process results into leaderboard format
leaderboard = []
for row in results:
# Add to leaderboard with elimination status
is_eliminated = row["email"] in eliminated_map
leaderboard.append({
"rank": 0, # Will be assigned after sorting
"email": row["email"],
"name": row["name"],
"score": row["challenge_score"] or 0,
"timestamp": row["timestamp"],
"eliminated": is_eliminated,
"elimination_challenge": eliminated_map.get(row["email"]),
"sort_total_time": row["response_time_seconds"] or 999999
})
# Sort by score and response time
leaderboard.sort(key=lambda x: (
1 if x["eliminated"] else 0, # Eliminated participants at the end
-x["score"], # Higher scores first
x["sort_total_time"] # Faster times first
))
# Assign ranks
rank = 1
for entry in leaderboard:
if not entry["eliminated"]:
entry["rank"] = rank
rank += 1
else:
entry["rank"] = "-"
return leaderboard
except Exception as e:
logger.error(f"Error getting leaderboard: {e}")
return []
Function that retrieves the contest participants and processes the leadboard sorting for a given challenge
3. Challenge qualification
Between challenges, we had to work out who moved on to the next round:
def transfer_qualified_participants(from_challenge, to_challenge):
"""Transfer qualified participants from one challenge to another."""
try:
# Find qualified participants from the previous challenge
query = """
SELECT p.id, p.email
FROM participants p
JOIN challenge_scores cs ON p.id = cs.participant_id
LEFT JOIN eliminated e ON p.id = e.participant_id AND e.challenge_number = %s
WHERE
cs.challenge_number = %s AND
e.participant_id IS NULL
"""
cursor.execute(query, (int(from_challenge), int(from_challenge)))
qualified_participants = cursor.fetchall()
# Insert these participants for the new challenge
for participant in qualified_participants:
participant_id = participant['id']
# Insert default score for new challenge
insert_query = """
INSERT IGNORE INTO challenge_scores
(participant_id, challenge_number, score, timestamp)
VALUES (%s, %s, 0, %s)
"""
cursor.execute(
insert_query,
(participant_id, int(to_challenge), datetime.now())
)
return True
except Exception as e:
logger.error(f"Error transferring participants: {e}")
return False
Function that transfers participants from a challenge to the next one
Enforcing the application
Aurora carried the database layer well. MySQL compatibility, a managed service we didn't have to operate, and performance that held up under load: it fit what we needed.
With Aurora next to S3 for form storage and FastAPI for the app logic, the pieces sat together cleanly and the experience held up for the people playing. And for us, the rest of the job was watching it closely, keeping it scaling, and making sure nothing fell over.
Infrastructure and DevOps: foundation of the application
We do a lot of DevOps, so we knew a sound infrastructure base would decide how well this went. Instead of bolting it on at the end, we made it a day-one priority, which is what let us iterate and deploy fast later.
We came at the AWS environment in layers, which let us move quickly without giving up stability or security.
Layer-based organization
1. Shared layer
The base everything else sits on:
- Networking (VPC, subnets, security groups)
- DNS and certificate management
- Common security controls (KMS, IAM)
- Shared storage resources
3. Application layer
Compute and service resources:
- ECS Fargate cluster for the FastAPI application
- Load balancer and target group
- CloudFront distributions for content delivery
- Auto-scaling configurations
2. Stateful layer
Persistent data components, like:
- Aurora MySQL Serverless
- S3 buckets for form storage
4. CI/CD layer
Deployment pipeline components:
- CodePipeline and CodeBuild resources
- ECR repositories for container images
- Deployment IAM roles and policies
Splitting it this way meant we could stand up the core infrastructure fast, then change one piece without shaking the whole stack.
The surprise
We were almost through challenge 2, so we figured it was time for challenge 3. We ended challenge 2, the backend automatically cut the bottom half (sorry, folks), and the leaderboard refreshed with the finalists heading into challenge 3.
We kicked off challenge 3, SMS notifications started flowing to the survivors, everything humming. Then, within seconds, people started walking up to our booth holding out their phones. Each screen showed something like this:

Oh boy. What now? We started working it ad hoc, step by step from the top, the way we usually debug. Something had broken our S3 pre-signed URLs.
A bit of digging turned up this resource.
Our S3 generate_presigned_url() call was made by the ECS task role that FastAPI ran under:
# Generate pre-signed URL that expires after 24 hours - NOT SO FAST!
form_url = s3_client.generate_presigned_url(
'get_object',
Params={
'Bucket': S3_BUCKET_NAME,
'Key': s3_path,
'ResponseContentDisposition': 'inline',
'ResponseContentType': 'text/html'
},
ExpiresIn=86400,
HttpMethod='GET'
)
Typical boto3 call for generation of pre-signed URLs
Here's the catch we learned from the AWS docs: a pre-signed URL generated by an IAM instance profile is only valid for up to 6 hours, no matter what expiry you ask for.
So how long had it been from generating the forms to starting challenge 3? A few minutes past six hours. Yep. A few minutes.
The solution
Once we knew what broke, the plan was simple: we need fresh pre-signed URLs. We started in CloudWatch logs (I'd been dumping every bit of info there, good call) and quickly found the challenge 3 participants in the noise.
We exported a CSV of the latest logs and used Claude to throw together a Python script that turned that big CSV into a friendly .txt file with only the fields we cared about.
We already had a VPN connection to Aurora ready (covered that from the start too), so we could run queries.
Then we wrote one more script: read the remaining users out of the .txt file, re-generate a pre-signed URL for each one, and write the new value back to the database.
I ran it locally and watched the terminal chew through users one by one. URLs are fresh now. Let's check. We hit it with a test user to confirm the new pre-signed URLs worked, and they did. We restarted challenge 3, the SMS notifications started flowing again, and new submissions came back in. Relief.
Another day at the office, another debug in production.
Conclusion
This one proved that the simple path isn't always the right one. Building it ourselves gave us full control, real-time performance, and the room to shape the experience exactly how we wanted. It also handed us a debugging session we won't forget, live in front of 200 cloud enthusiasts. Looking back, it was fun. (Really.)
And the iPad went to the champion who paired strong AWS knowledge with lightning-fast answers. Congrats again.
Meanwhile, we're already missing Timișoara. We came back with more experience than we left with, and we're already looking forward to next year's challenges.


