DEV Community

Cover image for AWS Use Cases | Spin Wheel | How big companies manage prize giveaways and prevent duplication at scale using AWS serverless
Minoltan Issack
Minoltan Issack

Posted on • Originally published at issackpaul95.Medium

AWS Use Cases | Spin Wheel | How big companies manage prize giveaways and prevent duplication at scale using AWS serverless

Introduction

Imagine the thrill of a spin-to-win promotion on your favorite e-commerce site. The wheel spins, the music builds, and for a few seconds, you’re on the edge of your seat, hoping for that big prize. For businesses, these gamified promotions are a goldmine: they drive immense user engagement, collect valuable data, and can significantly boost sales. But behind the scenes, managing such a giveaway for millions of eager users isn’t about luck — it’s about a robust, scalable architecture. The wrong approach can turn a successful campaign into a financial disaster, leaving you with an empty prize chest and a legion of frustrated customers.

This blog post will take you on a journey behind the curtain of a high-traffic spin-wheel promotion. We’ll explore the serious technical challenges that even the biggest companies face and, most importantly, show you how they build a bulletproof system using AWS Serverless to manage prize giveaways at scale and prevent a critical flaw: prize duplication.


The Problem in Simple Terms

Think of it like this: you have a single, highly-coveted prize — say, a brand new phone. You’ve announced that the first person to win on the spin wheel gets it.

Now, imagine thousands of people are all spinning the wheel at the same exact time. In the chaos of this high traffic, two different users (let’s call them Alice and Bob) hit the “spin” button within a fraction of a second of each other.

The problem, known as a race condition, occurs when your system’s prize check and prize deduction aren’t fast enough. The system might check for the phone, see that it’s available, and decide to award it to Alice. But before it can finalize that decision and remove the phone from the inventory, the system also checks for Bob, sees the same phone is available, and awards it to him as well.

The result? The system mistakenly gives the same prize to both Alice and Bob. This leads to frustrated customers, lost revenue, and a serious blow to your brand’s credibility. It’s the digital equivalent of two people trying to grab the last item on a shelf at the exact same time, but in this case, both of them walk away with it.


Our Solution in Simple Terms

So, how do big companies fix this problem? They don’t rely on luck. They use a special kind of database operation that acts like a digital bodyguard, ensuring a prize can only ever be claimed once.

Imagine our prize is the single, brand-new phone. When you spin the wheel and win, your request goes to a serverless program (an AWS Lambda function). This program doesn’t just check if the prize is there — it tries to claim it in a single, un-interruptible step.

This is the key. The program says to the database: “Give me this phone, but only if it’s still available.”

If the phone is available, the database immediately gives it to you and updates its records so no one else can see it. This all happens so fast and so securely that no other person’s request can get in the way.
If, however, another person (like Bob from our example) tried to claim the phone at the same time, their request would be denied instantly because the database would see that the prize count is now zero.

This special “all-or-nothing” command is what big companies use. It ensures that even with millions of spins, the system will never give out the same prize twice. You get your immediate result, and the company’s prize inventory stays perfectly accurate, keeping everyone happy.


The Grand Architecture: The Full Picture

This spin wheel module is designed as an independent, reusable component that can be integrated into any existing system (e.g., e-commerce, gaming app) via API calls. It uses AWS serverless services for scalability and low maintenance. You can also leverage my CDK implementation to easily deploy your application.

Key features:

1. Independence: The module exposes a REST API endpoint via API Gateway. Your existing system can call it with a user_id (authenticated via API key, JWT, or Cognito — I don’t use authorization for simplicity).

2. Race Condition Handling: Uses DynamoDB’s atomic conditional updates (optimistic locking) to prevent over-allocation of prizes or extra spins under high concurrency.

3. Prize Logic:

  • Each prize has a configurable weight (relative probability).
  • Overall prize winning probability (e.g., 0.3 for 30% chance of winning any prize) is configurable. Logic: First, generate a random number; if it’s less than overall_win_prob, select a prize based on weighted random; else, return “no prize”.
  • Prize stock is decremented atomically only if won and available.

4. Token Eligibility: Configurable max_spins (default 2) per user. Tracks spins_remaining in DynamoDB and token expiry date.


Step-by-step AWS setup

Step 1: Set Up DynamoDB Table

Use on-demand capacity for all tables to handle variable traffic.
For single table Approach we are using SpinWheel as the table name using PK is the partition key and SK is the sort key for the minimal maintenance.

1. Create global table

2. Create Config Record (for global configs):

Example Item:

{
  "PK": "GLOBAL",
  "SK": "CONFIG",
  "overall_win_prob": 0.3,
  "max_spins": 2,
  "token_expire_at": "2025-08-24T12:00:00Z",
  "last_updated": "2025-08-26T12:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

3. Create Prize Record

Example Items:

[
   {
      "PK":"PRIZE",
      "SK":"12345678",
      "name":"Mobile Phone",
      "initial_stock": 100,
      "available_stock":95
      "weight":10,
      "last_updated":"2025-08-26T12:00:00Z",
      "active":true
   },
   {
      "PK":"PRIZE",
      "SK":"18765432",
      "name":"Bag",
      "initial_stock": 1000,
      "available_stock":905
      "weight":50,
      "last_updated":"2025-08-25T12:00:00Z",
      "active":true
   },
   {
      "PK":"PRIZE",
      "SK":"28765432",
      "name":"1 Million",
      "initial_stock": 10,
      "available_stock":5
      "weight":1,
      "last_updated":"2025-08-24T12:00:00Z",
      "active":false
   }
]
Enter fullscreen mode Exit fullscreen mode

4. Token Record (for token history)
Example Item:

Note: The initial record will update when the user reads the token before the spin wheel click

[
{
  "PK": "PRIZE_TOKEN",
  "SK": "12345kd2k249dmm3sd", // token
  "spins_remaining": 0,
  "expire_at": "2025-08-26T12:00:00Z", 
  "prizes_won": [
    { 
      "id": "12345678",
      "name": "Mobile",
      "spin_timestamp": "2025-08-26T12:00:00Z",
      "chanceAt": 1
    }
   ]
},
{
  "PK": "PRIZE_TOKEN",
  "SK": "45645kd2k249dmm3sd",
  "spins_remaining": 2,
  "expire_at": "2025-08-26T12:00:00Z",
  "prizes_won": []
},
,
{
  "PK": "PRIZE_TOKEN",
  "SK": "12345df2k249dmm3sd",
  "spins_remaining": 0,
  "expire_at": "2025-08-26T12:00:00Z",
  "prizes_won": []
}
]
Enter fullscreen mode Exit fullscreen mode

Step 2: Set Up Lambda Function

A. Set Up Dependencies and Environment

  • Use Node.js 20.x runtime.
  • Dependencies: @aws-sdk/client-dynamodb (for DynamoDB operations).
  • No extra installs needed (crypto is built-in).
  • Env Vars: TABLE_NAME (e.g., “SpinWheel”).

B. Add Permission for the lambda

  • Go to claimSpinWheelRole (I AM role config)

  • Edit the permission

  • Add the permissions to access dynamo DB
  • Replace the account id
{
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:Query",
        "dynamodb:UpdateItem"
      ],
      "Resource": "arn:aws:dynamodb:*:<your-account-id>:table/SpinWheel"
}
Enter fullscreen mode Exit fullscreen mode

C. Upload the zip file with node modules

  • You can locally create the index.js file and install needed dependency using npm command.
  1. Initialize a Node.js Project: Create a new directory for your Lambda function and initialize a Node.js project.

mkdir spin-wheel-lambda
cd spin-wheel-lambda
npm init -y
Enter fullscreen mode Exit fullscreen mode
  1. Install @aws-sdk/client-dynamodb: Install the AWS SDK DynamoDB client.
npm install @aws-sdk/client-dynamodb
Enter fullscreen mode Exit fullscreen mode
  1. Verify package.json: After installation, your package.json should look like this:
{
  "name": "spin-wheel-lambda",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@aws-sdk/client-dynamodb": "^3.645.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: The version (3.645.0) may vary; use the latest stable version available at the time of installation.
consider this — “type”: “module”

  1. Create ddbClient.js
import {DynamoDBClient} from "@aws-sdk/client-dynamodb";

const REGION= "ap-southeast-1";
export const ddbClient = new DynamoDBClient({ region: REGION });
Enter fullscreen mode Exit fullscreen mode
  1. Create index.js
import { ddbClient } from "./ddbClient.js";
Enter fullscreen mode Exit fullscreen mode
  1. Create a ZIP File
zip -r spin-wheel-lambda.zip index.js ddbClient.js node_modules package.json package-lock.json
Enter fullscreen mode Exit fullscreen mode

  1. Upload the file to created lambda function

  1. Replace the lambda function

import { GetItemCommand, QueryCommand, TransactWriteItemsCommand } from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import { ddbClient } from "./ddbClient.js";

const ERROR_MESSAGES = {
    INVALID_TOKEN: 'Invalid token or no spins remaining',
    NO_PRIZE: 'No prizes available'
};
export const handler = async (event) => {
    const token = event.queryStringParameters?.token;

    try {
        const configItem = await loadConfig();
        const prizes = await loadEligiblePrizes();
        if (prizes.length === 0) {throw new Error("No prizes available");}

        const tokenItem = await checkTokenEligibility(token);
        if (!tokenItem || tokenItem.spins_remaining <= 0) {throw new Error(ERROR_MESSAGES.INVALID_TOKEN);}

        const overallWinProb = configItem.overall_win_prob;
        const result = await generateOutcomeAndUpdate(token, tokenItem.spins_total, tokenItem.spins_remaining, tokenItem.version, prizes, overallWinProb);

        return {
            statusCode: 200,
            headers: { 'Access-Control-Allow-Origin': '*' },
            body: JSON.stringify({
                outcome: result.outcome,
                prize: result.prizeWon,
                spins_remaining: tokenItem.spins_remaining - 1
            }),
        };

    } catch (error) {
        console.error({ level: 'ERROR', message: 'Handler error', error });
        if (error.message === ERROR_MESSAGES.INVALID_TOKEN) {
            return {
                statusCode: 400,
                headers: { 'Access-Control-Allow-Origin': '*' },
                body: JSON.stringify({ message: error.message }),
            };
        }

        if (error.message === ERROR_MESSAGES.NO_PRIZE) {
            return {
                statusCode: 200,
                headers: { 'Access-Control-Allow-Origin': '*' },
                body: JSON.stringify({ outcome: 'no_prize', message: error.message }),
            };
        }
        return {
            statusCode: 500,
            headers: { 'Access-Control-Allow-Origin': '*' },
            body: JSON.stringify({ message: 'Internal error' }),
        };
    }
};

const loadConfig = async () => {
    const data = await ddbClient.send(new GetItemCommand({
        TableName: process.env.DYNAMO_TABLE_NAME,
        Key: marshall({ PK: 'GLOBAL', SK: 'CONFIG' })
    }));
    if (!data.Item) throw new Error('Config not found');
    return unmarshall(data.Item);
};


const loadEligiblePrizes = async () => {
    const data = await ddbClient.send(new QueryCommand({
        TableName: process.env.DYNAMO_TABLE_NAME,
        KeyConditionExpression: 'PK = :pk',
        FilterExpression: 'active = :true AND available_stock > :zero AND weight > :zero',
        ProjectionExpression: 'SK, #name, available_stock, weight, version',
        ExpressionAttributeNames: { '#name': 'name' },
        ExpressionAttributeValues: marshall({
            ':pk': 'PRIZE',
            ':true': true,
            ':zero': 0
        })
    }));
    return data.Items.map(item => unmarshall(item));
};

const checkTokenEligibility = async (token) => {
    const data = await ddbClient.send(new GetItemCommand({
        TableName: process.env.DYNAMO_TABLE_NAME,
        Key: marshall({ PK: 'PRIZE_TOKEN', SK: token })
    }));
    const item = data.Item ? unmarshall(data.Item) : null;
    if (!item || new Date(item.expire_at) <= new Date()) {
        return null;
    }
    return {
        spins_remaining: item.spins_remaining,
        spins_total: item.spins_total,
        version: item.version
    };
};


const generateOutcomeAndUpdate = async (token, spinsTotal, spinsRemaining, tokenVersion, prizes, overallWinProb) => {
    const result = calculateSpinOutcome(prizes, overallWinProb);
    const now = new Date().toISOString();
    const chanceAt = spinsTotal - spinsRemaining + 1;
    const transactItems = createTransactionItems(token, tokenVersion, spinsRemaining, result.prize, now, chanceAt);

    await ddbClient.send(new TransactWriteItemsCommand({ TransactItems: transactItems }));

    return {
        status: 'success',
        outcome: result.outcome,
        prizeWon: result.prize ? { id: result.prize.SK ,name:result.prize.name, spin_timestamp: now, chanceAt } : null
    };
};

const calculateSpinOutcome = (prizes, overallWinProb) => {
    if (Math.random() > overallWinProb) {
        return { outcome: 'no_prize', prize: null };
    }

    const totalWeight = prizes.reduce((sum, p) => sum + p.weight, 0);
    if (totalWeight === 0) {
        return { outcome: 'no_prize', prize: null };
    }

    let cumulative = 0;
    const randWeight = Math.random() * totalWeight;
    for (const prize of prizes) {
        cumulative += prize.weight;
        if (randWeight < cumulative) {
            return { outcome: 'win', prize };
        }
    }
    return { outcome: 'no_prize', prize: null }; // Fallback
};

const createTransactionItems = (token, tokenVersion, spinsRemaining, prize, now, chanceAt) => {
    const transactItems = [{
        Update: {
            TableName: process.env.DYNAMO_TABLE_NAME,
            Key: marshall({ PK: 'PRIZE_TOKEN', SK: token }),
            UpdateExpression: 'SET spins_remaining = spins_remaining - :one, version = version + :one',
            ConditionExpression: 'spins_remaining > :zero AND version = :currentVersion',
            ExpressionAttributeValues: marshall({
                ':one': 1,
                ':zero': 0,
                ':currentVersion': tokenVersion
            })
        }
    }];

    if (prize) {
        transactItems[0].Update.UpdateExpression += ', prizes_won = list_append(prizes_won, :prizeList)';
        transactItems[0].Update.ExpressionAttributeValues = {
            ...transactItems[0].Update.ExpressionAttributeValues,
            ...marshall({
                ':prizeList': [{
                    id: prize.SK,
                    name: prize.name,
                    spin_timestamp: now,
                    chanceAt: chanceAt
                }]
            })
        };
        transactItems.push({
            Update: {
                TableName: process.env.DYNAMO_TABLE_NAME,
                Key: marshall({ PK: 'PRIZE', SK: prize.SK }),
                UpdateExpression: 'SET available_stock = available_stock - :one, version = version + :one',
                ConditionExpression: 'available_stock > :zero AND active = :true AND version = :currentVersion',
                ExpressionAttributeValues: marshall({
                    ':one': 1,
                    ':zero': 0,
                    ':true': true,
                    ':currentVersion': prize.version
                })
            }
        });
    }
    return transactItems;
};
Enter fullscreen mode Exit fullscreen mode

API Integration

1. Create Rest API

  1. Give name

  1. Create root resource

  1. Create Post Method

  1. Deploy API with stage


For Full Spin Wheel Module in CDK

Visit My GitHub


Conclusion

Running a spin-to-win promotion at scale is far more than just adding a flashy wheel to your website — it’s a complex engineering challenge. Without the right safeguards, race conditions, duplicate prizes, and user frustration can quickly turn what should be an exciting campaign into a business nightmare. By leveraging AWS Serverless components like DynamoDB, Lambda, API Gateway, and managing everything with CDK, we can design a system that is scalable, reliable, and cost-efficient. The key lies in atomic operations and transactional logic, ensuring that no matter how many users spin at the same time, your prize inventory stays accurate and your customers get a fair experience.


To stay informed on the latest technical insights and tutorials, connect with me on Medium, LinkedIn and Dev.to. For professional inquiries or technical discussions, please contact me via email. I welcome the opportunity to engage with fellow professionals and address any questions you may have.

Top comments (0)