Serverless

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.

safeINIT

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

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:

  1. Complete control and personalization – we could shape every bit of how the form looked and behaved

  2. Response time tracking – we could measure how fast each participant finished a challenge

  3. Lifecycle management – forms moved through a clear path from active to answered or unanswered

  4. Scalability – the system handled hundreds of concurrent participants without breaking a sweat

  5. 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:

s3-access-denied

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.