Serverless Game Leaderboard Setup: 14 Steps, 90 Min [2026]

Most “cloud gaming” tutorials in 2026 are about streaming a console to a browser. This one is about something more boring and more useful: the invisible plumbing that keeps score. If you are building a game that needs a live leaderboard, real-time match updates, or a player-ranking system that does not fall over during a launch-day spike, you do not need a fleet of dedicated servers. You need a serverless backend built on AWS Lambda, Amazon API Gateway WebSocket APIs, and Amazon DynamoDB, provisioned with Terraform so it can be torn down and rebuilt in minutes.

This tutorial walks through building that exact system from scratch: a leaderboard API that accepts score submissions, ranks players with a DynamoDB global secondary index, and pushes live rank changes to connected clients over a persistent WebSocket connection. No game server to patch, no EC2 fleet to babysit, and a bill that scales down to near-zero when nobody is playing. By the end you will have a working project you can deploy to your own AWS account in under 90 minutes.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why a serverless leaderboard beats a dedicated server for most games

A traditional leaderboard service runs on a server that sits idle most of the day and gets hammered during peak hours. That mismatch is exactly what serverless architecture is designed to fix. AWS’s own Well-Architected Games Industry Lens documents a serverless-based game backend architecture built on Lambda, API Gateway, DynamoDB, SNS, and ElastiCache precisely because game traffic is bursty by nature: quiet for hours, then a spike the moment a streamer features your title or a weekend tournament starts. This pairs naturally with a real-time game analytics pipeline if you also want to track player behavior alongside scores, and with the broader cloud computing coverage on this site.

With Lambda, each function scales independently and you pay only for execution time, not idle capacity. With DynamoDB in on-demand mode, the table scales read and write throughput automatically. With API Gateway WebSocket APIs, a client opens one persistent connection and the backend pushes updates to it, rather than the client polling a REST endpoint every few seconds (which is what a surprising number of shipped games still do, and it is both slower and more expensive at scale).

The tradeoff: this stack is not meant to replace an authoritative, tick-based game server for a twitch shooter. It is built for leaderboards, turn-based state, lobby and matchmaking metadata, and lightweight real-time features — the categories of game-backend traffic that do not need sub-20ms server authority. If your game needs that, pair this leaderboard with a dedicated server layer; this guide focuses purely on the leaderboard and live-ranking path.

How this compares to GameLift, Agones, and Nakama

If you have already looked at Amazon GameLift, Agones on Kubernetes, or Nakama for your backend, it is worth being clear about where a serverless leaderboard fits relative to those options rather than competing with them directly. GameLift and Agones both manage fleets of long-running game server processes, the kind of infrastructure a real-time shooter or battle royale title needs for authoritative tick-based simulation. Nakama is a self-hosted or managed game server framework that bundles matchmaking, chat, and storage into one runtime you operate yourself. None of the three is built primarily around a sorted, globally queryable ranking table pushed to clients over a managed WebSocket layer, which is the specific problem this tutorial solves.

ApproachBest forOps burdenIdle cost
Serverless leaderboard (this tutorial)Leaderboards, rankings, turn-based state, lobby metadataLow — no servers to patchNear zero
Amazon GameLiftAuthoritative real-time match sessionsMedium — fleet and build managementDepends on fleet sizing
Agones on KubernetesSelf-managed dedicated game servers at scaleHigh — you own the clusterCluster runs continuously
NakamaFull game backend framework, self-hosted or managedMedium — runtime to operate or a hosted plan to pay forRuns continuously unless scaled to zero manually

In practice, a lot of shipped multiplayer games run more than one of these at once: GameLift or Agones for the match itself, and a serverless leaderboard layer like the one built in this tutorial sitting next to it for rankings, profile data, and the kind of lightweight real-time updates that do not need an authoritative tick-rate server. Treat this guide as the leaderboard layer of a larger architecture, not a wholesale replacement for a dedicated game server when your game genuinely needs one.

Prerequisites and versions used in this tutorial

You do not need a game engine to follow along since this is a backend tutorial, and the sample client is a plain HTML page with a few lines of JavaScript. You do need an AWS account and a terminal. Here is exactly what was used to build and test this project:

ToolVersion usedInstall command
AWS CLIv2 (latest)curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
Terraform1.9.x or newerbrew install terraform or official binary
Node.js20.x LTS (Lambda runtime nodejs20.x)nvm install 20
AWS accountIAM user with programmatic accessconsole.aws.amazon.com
wscat (optional, for testing)latest via npmnpm install -g wscat

Budget 60 to 90 minutes for the full build, including Terraform apply time. DynamoDB tables and API Gateway deployments typically provision in under two minutes, so most of that time goes into writing and testing the Lambda code, not waiting on AWS. You will need an AWS account with billing enabled; the free tier covers this entire tutorial for testing purposes, and ongoing cost at low traffic runs a few cents a month.

Step 1: Design the data model before you write any code

The single most common mistake in leaderboard projects is designing the DynamoDB table after the Lambda functions are already written. Do it the other way around. A leaderboard has two access patterns that matter: write a score, and read the top N scores sorted descending. DynamoDB does not sort by a numeric attribute natively unless you build a global secondary index (GSI) around it, so the table design needs to bake that in from the start.

The table in this tutorial uses a single-table design with three logical item types: player scores, WebSocket connection records, and leaderboard metadata. Here is the key schema:

AttributeTypeRole
PKStringPartition key, e.g. LEADERBOARD#global
SKStringSort key, e.g. PLAYER#alice123
scoreNumberCurrent best score
gsi1pkStringGSI partition key, same value as PK for ranking queries
gsi1skNumberGSI sort key, set to score for descending rank order
updatedAtNumberEpoch timestamp of the last score update

The GSI (named LeaderboardRankIndex in this project) is what makes “get the top 100 players” a single, cheap, sorted query instead of a full table scan. The AWS DynamoDB global secondary index documentation explains that a GSI lets you query on a different key than the table’s primary key while DynamoDB maintains it automatically as items are written, which is exactly the pattern a leaderboard needs.

Step 2: Initialize the Terraform project structure

Create a project directory and lay out Terraform files by resource type rather than dumping everything into one file. This keeps the project navigable once you add more Lambda functions later.

mkdir serverless-leaderboard && cd serverless-leaderboard
mkdir -p terraform lambda/submit-score lambda/get-leaderboard lambda/ws-connect lambda/ws-disconnect lambda/ws-broadcast

touch terraform/main.tf terraform/variables.tf terraform/outputs.tf \
      terraform/dynamodb.tf terraform/lambda.tf terraform/apigateway.tf

terraform -chdir=terraform init

In terraform/main.tf, declare the AWS provider and a remote backend if you already use one for other projects, typically an S3 bucket with DynamoDB state locking, as shown in most Terraform AWS provider documentation examples. If you are new to Terraform on AWS, the Terraform on AWS setup tutorial on this site covers provider configuration and state management in more depth. For a single-developer test project, a local state file is fine to start.

terraform {
  required_version = ">= 1.9.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

Step 3: Provision the DynamoDB table with Terraform

Add the table definition in terraform/dynamodb.tf. Use PAY_PER_REQUEST billing mode so you are not pre-provisioning read and write capacity units for traffic you cannot predict yet. This is the single biggest cost-control decision in this whole project, and switching to provisioned capacity later, once your traffic is predictable, is a one-line change.

resource "aws_dynamodb_table" "leaderboard" {
  name         = "leaderboard-table"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "PK"
  range_key    = "SK"

  attribute {
    name = "PK"
    type = "S"
  }
  attribute {
    name = "SK"
    type = "S"
  }
  attribute {
    name = "gsi1pk"
    type = "S"
  }
  attribute {
    name = "gsi1sk"
    type = "N"
  }

  global_secondary_index {
    name            = "LeaderboardRankIndex"
    hash_key        = "gsi1pk"
    range_key       = "gsi1sk"
    projection_type = "ALL"
  }

  point_in_time_recovery {
    enabled = true
  }

  tags = {
    Project = "serverless-leaderboard"
  }
}

Run terraform -chdir=terraform plan to confirm the table resource looks correct before applying anything else. Point-in-time recovery costs a small percentage on top of storage and is worth enabling from day one. Restoring a corrupted leaderboard table manually, after the fact, is a miserable way to spend an afternoon.

Step 4: Write the score-submission Lambda function

This function receives a player ID and a score, validates it, and writes it to DynamoDB only if it beats the player’s existing best, a common leaderboard rule that avoids a sloppy score overwriting a better one submitted earlier in the session.

// lambda/submit-score/index.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb";

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;

export const handler = async (event) => {
  const body = JSON.parse(event.body || "{}");
  const { playerId, score, board = "global" } = body;

  if (!playerId || typeof score !== "number" || score < 0) {
    return { statusCode: 400, body: JSON.stringify({ error: "invalid playerId or score" }) };
  }

  const pk = `LEADERBOARD#${board}`;
  const sk = `PLAYER#${playerId}`;

  // Conditional update: only write if score is higher than current best
  await client.send(new UpdateCommand({
    TableName: TABLE,
    Key: { PK: pk, SK: sk },
    UpdateExpression: "SET score = :s, gsi1pk = :pk, gsi1sk = :s, updatedAt = :t",
    ConditionExpression: "attribute_not_exists(score) OR score < :s",
    ExpressionAttributeValues: {
      ":s": score,
      ":pk": pk,
      ":t": Date.now(),
    },
  })).catch((err) => {
    if (err.name !== "ConditionalCheckFailedException") throw err;
  });

  return { statusCode: 200, body: JSON.stringify({ accepted: true }) };
};

The ConditionExpression is doing the real work here. Without it, a late-arriving network request with a lower score could overwrite a legitimate high score, a race condition that is easy to miss in testing, where requests arrive in order, and painful to debug in production, where they do not.

Step 5: Write the leaderboard-query Lambda function

This function queries the GSI for the top N players, sorted descending. DynamoDB GSIs sort ascending by default, so the query needs ScanIndexForward: false to reverse that.

// lambda/get-leaderboard/index.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME;

export const handler = async (event) => {
  const board = event.queryStringParameters?.board || "global";
  const limit = Number(event.queryStringParameters?.limit || 20);

  const result = await client.send(new QueryCommand({
    TableName: TABLE,
    IndexName: "LeaderboardRankIndex",
    KeyConditionExpression: "gsi1pk = :pk",
    ExpressionAttributeValues: { ":pk": `LEADERBOARD#${board}` },
    ScanIndexForward: false,
    Limit: limit,
  }));

  const ranked = result.Items.map((item, i) => ({
    rank: i + 1,
    playerId: item.SK.replace("PLAYER#", ""),
    score: item.score,
  }));

  return { statusCode: 200, body: JSON.stringify({ board, leaderboard: ranked }) };
};

Step 6: Deploy the Lambda functions with Terraform

Each function needs an IAM role scoped to exactly what it touches: DynamoDB read and write on this one table, nothing broader. In terraform/lambda.tf:

resource "aws_iam_role" "lambda_exec" {
  name = "leaderboard-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "dynamo_access" {
  role = aws_iam_role.lambda_exec.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Query", "dynamodb:GetItem"]
      Resource = [aws_dynamodb_table.leaderboard.arn, "${aws_dynamodb_table.leaderboard.arn}/index/*"]
    }]
  })
}

resource "aws_lambda_function" "submit_score" {
  function_name = "leaderboard-submit-score"
  role          = aws_iam_role.lambda_exec.arn
  handler       = "index.handler"
  runtime       = "nodejs20.x"
  filename      = "../lambda/submit-score/function.zip"
  timeout       = 5
  environment {
    variables = { TABLE_NAME = aws_dynamodb_table.leaderboard.name }
  }
}

Repeat the aws_lambda_function block for get-leaderboard, ws-connect, ws-disconnect, and ws-broadcast, pointing each at its own zipped source. Package each function with cd lambda/submit-score && npm install --production && zip -r function.zip . before running terraform apply.

Step 7: Set up the API Gateway WebSocket API

This is the piece most tutorials skip over, and it is the part that turns a static leaderboard into a live one. The AWS API Gateway WebSocket API documentation describes how a WebSocket API maintains a persistent, full-duplex connection between client and backend, routing messages to different backend integrations based on message content rather than requiring a new HTTP request for every exchange.

Every WebSocket API needs three special routes: $connect, $disconnect, and a catch-all or custom route for messages. Define them in terraform/apigateway.tf:

resource "aws_apigatewayv2_api" "ws" {
  name                       = "leaderboard-ws-api"
  protocol_type              = "WEBSOCKET"
  route_selection_expression = "$request.body.action"
}

resource "aws_apigatewayv2_route" "connect" {
  api_id    = aws_apigatewayv2_api.ws.id
  route_key = "$connect"
  target    = "integrations/${aws_apigatewayv2_integration.connect.id}"
}

resource "aws_apigatewayv2_route" "disconnect" {
  api_id    = aws_apigatewayv2_api.ws.id
  route_key = "$disconnect"
  target    = "integrations/${aws_apigatewayv2_integration.disconnect.id}"
}

resource "aws_apigatewayv2_stage" "prod" {
  api_id      = aws_apigatewayv2_api.ws.id
  name        = "prod"
  auto_deploy = true
}

The $connect route fires when a client opens a WebSocket connection. Its Lambda function, ws-connect, should store the connection ID in DynamoDB so the broadcast function can find it later:

// lambda/ws-connect/index.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";

const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export const handler = async (event) => {
  const connectionId = event.requestContext.connectionId;
  await client.send(new PutCommand({
    TableName: process.env.TABLE_NAME,
    Item: {
      PK: "CONNECTION#global",
      SK: `CONN#${connectionId}`,
      connectionId,
      ttl: Math.floor(Date.now() / 1000) + 7200,
    },
  }));
  return { statusCode: 200, body: "connected" };
};

Note the ttl field. If a client disconnects without triggering $disconnect cleanly, say a closed laptop lid or dropped Wi-Fi, a stale connection record left behind will cause a GoneException the next time the broadcast function tries to post to it. Setting a TTL on the item and enabling DynamoDB’s TTL feature on that attribute means dead connections quietly expire instead of accumulating forever.

Step 8: Broadcast live rank changes to connected clients

After a score is accepted in the submit-score function, trigger a broadcast function that scans the connections table and pushes the updated top-N list to every open socket using the API Gateway Management API.

// lambda/ws-broadcast/index.mjs
import { ApiGatewayManagementApiClient, PostToConnectionCommand } from "@aws-sdk/client-apigatewaymanagementapi";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const apiClient = new ApiGatewayManagementApiClient({ endpoint: process.env.WS_ENDPOINT });

export const handler = async (event) => {
  const payload = JSON.stringify(event.detail); // top-N leaderboard snapshot

  const conns = await ddb.send(new QueryCommand({
    TableName: process.env.TABLE_NAME,
    KeyConditionExpression: "PK = :pk",
    ExpressionAttributeValues: { ":pk": "CONNECTION#global" },
  }));

  await Promise.allSettled(conns.Items.map((c) =>
    apiClient.send(new PostToConnectionCommand({
      ConnectionId: c.connectionId,
      Data: Buffer.from(payload),
    })).catch((err) => {
      if (err.name !== "GoneException") throw err;
    })
  ));

  return { statusCode: 200 };
};

The GoneException catch is not optional. Without it, one stale connection throws an unhandled error that can abort the whole broadcast before the remaining, still-connected clients get their update. Catching it and moving on is the difference between one client missing an update and every client missing an update.

Step 9: Wire up the HTTP API for score submission and queries

While the WebSocket API handles live push updates, score submission and one-off leaderboard reads are cleaner over a standard HTTP API, which is cheaper per request and easier to cache at the CDN layer than WebSocket traffic. Add an aws_apigatewayv2_api resource with protocol_type = "HTTP" alongside the WebSocket one, with two routes: POST /scores wired to submit_score, and GET /leaderboard wired to get_leaderboard.

resource "aws_apigatewayv2_api" "http" {
  name          = "leaderboard-http-api"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_integration" "submit_score" {
  api_id                 = aws_apigatewayv2_api.http.id
  integration_type       = "AWS_PROXY"
  integration_uri        = aws_lambda_function.submit_score.invoke_arn
  payload_format_version = "2.0"
}

resource "aws_apigatewayv2_route" "post_scores" {
  api_id    = aws_apigatewayv2_api.http.id
  route_key = "POST /scores"
  target    = "integrations/${aws_apigatewayv2_integration.submit_score.id}"
}

resource "aws_apigatewayv2_stage" "http_prod" {
  api_id      = aws_apigatewayv2_api.http.id
  name        = "prod"
  auto_deploy = true
}

Step 10: Apply the Terraform configuration and deploy

With every resource defined, package the Lambda functions and run the full apply:

for fn in submit-score get-leaderboard ws-connect ws-disconnect ws-broadcast; do
  cd lambda/$fn
  npm install --production
  zip -r function.zip . -x "*.git*"
  cd ../..
done

terraform -chdir=terraform plan -out=tfplan
terraform -chdir=terraform apply tfplan

Terraform prints the HTTP API invoke URL and the WebSocket API endpoint as outputs once the apply finishes. A clean apply for this exact stack typically takes 60 to 120 seconds. DynamoDB tables and Lambda functions provision fast; API Gateway stages are slightly slower.

Step 11: Test the leaderboard end to end

Submit a test score over HTTP, then confirm it shows up in a leaderboard query:

# Submit a score
curl -X POST https://YOUR_HTTP_API_ID.execute-api.us-east-1.amazonaws.com/prod/scores \
  -H "Content-Type: application/json" \
  -d '{"playerId": "alice123", "score": 9850}'

# Query the top 10
curl "https://YOUR_HTTP_API_ID.execute-api.us-east-1.amazonaws.com/prod/leaderboard?limit=10"

Expected output example:

{
  "board": "global",
  "leaderboard": [
    { "rank": 1, "playerId": "alice123", "score": 9850 },
    { "rank": 2, "playerId": "bob456", "score": 8720 },
    { "rank": 3, "playerId": "carla789", "score": 8010 }
  ]
}

Then test the live push path by connecting with wscat in one terminal and submitting a score from another:

wscat -c wss://YOUR_WS_API_ID.execute-api.us-east-1.amazonaws.com/prod

If the broadcast Lambda is wired correctly, submitting a new high score in the other terminal pushes a JSON payload into the open wscat session within a second or two, with no polling involved on the client side.

Step 12: Add CloudWatch monitoring for the leaderboard pipeline

A serverless stack hides its moving parts behind managed services, which makes monitoring more important, not less. Without visibility into Lambda errors, DynamoDB throttling, and WebSocket connection counts, the first sign of trouble is a player complaint rather than an alert. Add a CloudWatch dashboard and a handful of alarms before you consider this project production-ready.

resource "aws_cloudwatch_metric_alarm" "lambda_errors" {
  alarm_name          = "leaderboard-submit-score-errors"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "Errors"
  namespace           = "AWS/Lambda"
  period              = 60
  statistic           = "Sum"
  threshold           = 5
  dimensions = {
    FunctionName = aws_lambda_function.submit_score.function_name
  }
  alarm_actions = [aws_sns_topic.alerts.arn]
}

resource "aws_cloudwatch_metric_alarm" "dynamo_throttle" {
  alarm_name          = "leaderboard-table-throttled-requests"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "ThrottledRequests"
  namespace           = "AWS/DynamoDB"
  period              = 60
  statistic           = "Sum"
  threshold           = 0
  dimensions = {
    TableName = aws_dynamodb_table.leaderboard.name
  }
  alarm_actions = [aws_sns_topic.alerts.arn]
}

Three metrics matter most in this architecture: Lambda error count and duration (a sudden jump in duration on the broadcast function usually means the connections table has grown large enough to need the EventBridge fan-out pattern described in the advanced tips below), DynamoDB throttled requests (which should be zero on on-demand billing mode, and a non-zero value usually signals a hot partition key rather than a capacity problem), and the WebSocket API’s ConnectCount and MessageCount metrics, which tell you whether broadcast volume is tracking player activity the way you expect.

Route the aws_sns_topic.alerts topic to an email subscription or a Slack webhook through Chatbot so an alarm actually reaches someone instead of sitting unread in the CloudWatch console. A leaderboard that silently stops accepting scores for six hours because of a bad deploy is a worse outcome than a false-positive alert at 2 a.m.

Step 13: Add friends-only and time-windowed leaderboard variants

Most real games need more than one leaderboard: a global all-time board, a weekly or seasonal board, and sometimes a friends-only board. The single-table design from Step 1 already supports this since each board is just a different PK value, such as LEADERBOARD#global, LEADERBOARD#weekly-2026-40, or LEADERBOARD#friends-alice123. To add a weekly board, compute the ISO week number client-side or in the Lambda function and pass it as the board parameter; no schema change is required.

For a time-windowed board that should reset automatically, set a DynamoDB TTL on the weekly items so old boards clean themselves up rather than accumulating stale data that nobody queries again.

Step 14: Add rate limiting and basic anti-cheat validation

A publicly reachable score-submission endpoint is an open invitation for a player to just curl a fake world record into your leaderboard. Two concrete mitigations belong in this project, not a future-work list:

  • Usage plans and API keys, or a WAF rate-based rule, on the HTTP API to cap how many score submissions a single client can make per minute.
  • Server-side plausibility checks in the submit-score Lambda, rejecting scores above a hard ceiling your game design knows is mathematically impossible, and logging anything close to that ceiling for manual review instead of silently accepting it.

Neither of these is a complete anti-cheat system, but both are cheap, and skipping them is how a leaderboard launch turns into a leaderboard full of nonsense scores within 48 hours.

Cost breakdown: what this actually costs to run

One advantage of this architecture over a dedicated server is that the idle cost sits close to zero. Here is a rough estimate based on published 2026 pricing for the core services:

ServicePricing modelApprox. cost at 100K score submissions/month
AWS LambdaPer request plus GB-second, per AWS Lambda pricingUnder $1/mo at this volume, often within free tier
DynamoDB on-demandPer read/write request unit, per DynamoDB on-demand pricingA few dollars/mo depending on item size and GSI writes
API Gateway HTTP APIPer million requestsWell under $1/mo at 100K requests
API Gateway WebSocket APIPer message plus per connection-minuteDepends heavily on concurrent connections and broadcast frequency

The one line item to watch is WebSocket connection-minutes. If your game keeps thousands of idle sockets open for hours, that adds up faster than the per-message cost does. For a casual mobile game where players check the leaderboard a few times a session rather than staying connected continuously, this stays cheap. For a game that keeps every player connected live for a 30-minute match, model that traffic before committing to this architecture at scale.

Common pitfalls when building a serverless game leaderboard

  • Scanning instead of querying. Calling Scan on the leaderboard table instead of Query on the GSI works fine with 50 test players and falls over the moment you have 50,000, because scans read every item and get slower and more expensive as the table grows.
  • No TTL on connection records. Dead WebSocket connections pile up in DynamoDB forever if you never clean them up, and every broadcast wastes a call trying, and failing, to reach them.
  • Treating API Gateway WebSocket like a raw TCP socket. It is message-based and stateless between invocations; you cannot hold state in Lambda memory between messages the way you could with a long-running server process. All state has to live in DynamoDB.
  • Skipping the conditional write on score updates. Without it, out-of-order requests can silently downgrade a player’s recorded best score.
  • Using provisioned DynamoDB capacity before you know your traffic shape. On-demand mode costs more per request but removes the risk of throttling during a launch spike, which is the worse outcome for a new game.
  • Forgetting IAM least-privilege. Giving every Lambda function full dynamodb:* access instead of scoping to the specific table and actions it needs is a common and avoidable security gap.
  • Not handling GoneException in the broadcast function. One stale connection ID can abort an entire broadcast loop if the error is not caught per connection.
  • Hardcoding the WebSocket management API endpoint. It changes per API ID and stage; pull it from Terraform outputs or environment variables, not a hardcoded string.

Troubleshooting guide

  • WebSocket connects then immediately disconnects: check that the $connect Lambda returns a 200 status code. Any non-200 response causes API Gateway to reject the connection.
  • Score submissions silently do nothing: check CloudWatch Logs for the submit-score function for a ConditionalCheckFailedException. This is expected behavior when a lower score is submitted, not a bug, but it is easy to mistake for a silent failure.
  • Leaderboard query returns an empty list: verify the GSI has finished backfilling after table creation. A newly created GSI on an existing table can take time to populate, though on a fresh table this is near-instant.
  • Broadcast function throws AccessDeniedException: the Lambda execution role needs execute-api:ManageConnections permission on the WebSocket API ARN, separate from the DynamoDB permissions.
  • Terraform apply fails with a duplicate route key error: you likely defined two routes with the same route_key on the same API, and API Gateway requires uniqueness per API.
  • Clients receive duplicate broadcast messages: check whether the submit-score function is triggering the broadcast function more than once per request, often from a retried Lambda invocation after a timeout.
  • High DynamoDB costs appear overnight: check for a loop accidentally calling Scan instead of Query, or a broadcast function running on every single connection-check instead of only on score changes.
  • wscat connects locally but production clients fail to connect: check CORS and WAF rules on the API Gateway stage, and confirm the client uses wss://, not ws://, for the production endpoint.

Security checklist before going live

Before pointing a real game client at this backend, run through a short checklist. None of these items are exotic, but skipping any one of them is a common way a side project turns into an incident report.

  • IAM roles scoped per function. The submit-score function should not be able to delete the table, and the get-leaderboard function should not need write permissions at all. Review each aws_iam_role_policy against the actions that Lambda function actually calls.
  • Authentication on both APIs. Add a Cognito user pool authorizer or a Lambda authorizer to the HTTP API and the WebSocket API’s $connect route so an anonymous client cannot submit scores as an arbitrary player ID.
  • Input validation on every field. The sample submit-score function checks type and range on score; add length and character checks on playerId too, since it flows directly into a DynamoDB key.
  • Logging without leaking secrets. CloudWatch Logs are invaluable for debugging but should never contain raw authorization tokens or full request bodies in production; redact before logging.
  • A WAF web ACL in front of the HTTP API for basic rate limiting and known-bad-IP blocking, especially before a public launch or a marketing push that could draw bot traffic.

Advanced tips: scaling beyond the basic build

Once the basic leaderboard works, a few upgrades matter at real scale. First, if your game has hundreds of thousands of concurrent WebSocket connections, consider fanning broadcasts out through SNS or EventBridge rather than looping over connections inside a single Lambda invocation. A single function has a 15-minute hard timeout and finite memory, and a loop over 100,000 connection records will not finish in time.

Second, for very read-heavy leaderboards, such as a popular game where thousands of players check rankings per minute but scores change relatively rarely, put a short-TTL cache in front of the get-leaderboard Lambda using API Gateway’s built-in caching or a small Redis layer, so you are not re-querying DynamoDB for an identical top-10 list from two different players within the same second.

Third, separate the broadcast trigger from the submit-score function using EventBridge so a slow or failing broadcast never delays the score-submission response the player’s game client is waiting on. The submit function should return success the instant the score is durably written, not after every connected client has received the push.

Complete project structure

With all fourteen steps complete, the finished project directory looks like this. Keeping each Lambda function in its own folder with its own package.json keeps deployment packages small, which matters for Lambda cold-start time, and makes it obvious which function owns which dependency.

serverless-leaderboard/
├── terraform/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   ├── dynamodb.tf
│   ├── lambda.tf
│   ├── apigateway.tf
│   └── monitoring.tf
├── lambda/
│   ├── submit-score/
│   │   ├── index.mjs
│   │   └── package.json
│   ├── get-leaderboard/
│   │   ├── index.mjs
│   │   └── package.json
│   ├── ws-connect/
│   │   ├── index.mjs
│   │   └── package.json
│   ├── ws-disconnect/
│   │   ├── index.mjs
│   │   └── package.json
│   └── ws-broadcast/
│       ├── index.mjs
│       └── package.json
└── client/
    └── index.html

The client/index.html file is a minimal test harness: a text input for a player ID and score, a submit button that calls the HTTP API, and a small script that opens the WebSocket connection and renders incoming leaderboard payloads into a list. It is intentionally not a real game UI. Its only job is proving the backend works before you wire it into an actual game client, whether that is a Unity project, a web game, or a mobile app.

Tearing down the project

Because everything is defined in Terraform, cleanup is a single command, which also makes this a safe project to test and iterate on without worrying about leaving billable resources running:

terraform -chdir=terraform destroy

Confirm the DynamoDB table, both API Gateway APIs, the Lambda functions, and the IAM role all disappear from the AWS console afterward. If point-in-time recovery was enabled and you want a final backup, export it before running destroy.

Frequently asked questions

Do I need Amazon GameLift for this, or does this replace it?
This replaces GameLift for leaderboard and ranking functionality specifically. GameLift is built for hosting authoritative, tick-based dedicated game server sessions, a different problem entirely. Many production games run both: GameLift, or a Kubernetes-based server fleet, for live match sessions, and this kind of serverless backend for leaderboards, profiles, and matchmaking metadata.

Can I use this architecture for a mobile game with millions of players?
Yes, with the scaling adjustments described in the advanced tips section, specifically decoupling the broadcast step from the write path with EventBridge or SNS once concurrent WebSocket connections climb into the tens of thousands.

Why DynamoDB instead of a relational database for the leaderboard?
DynamoDB’s on-demand scaling and native support for a sorted global secondary index map directly onto the leaderboard access pattern of writing one score and reading the top N sorted by score, without needing the manual index tuning or connection-pool management that a relational database at this request volume would require.

What happens if a player is offline when a broadcast fires?
The broadcast simply fails for that connection ID with a GoneException, which the code catches and ignores. When the player reconnects, have the client call the HTTP GET /leaderboard endpoint immediately on connect to get the current state, rather than waiting for the next broadcast.

Is API Gateway WebSocket the same as Socket.IO or raw WebSockets in Node.js?
No. API Gateway WebSocket APIs are stateless between messages from the Lambda function’s perspective. The connection is held open by API Gateway, but each message triggers a fresh, short-lived Lambda invocation rather than a long-running process holding the socket. All session state has to be persisted externally, typically in DynamoDB, as shown in this tutorial.

How do I add authentication so players can’t submit scores for other players?
Add an authorizer to both the HTTP and WebSocket APIs, either a Lambda authorizer or an Amazon Cognito user pool authorizer, and extract the verified player ID from the validated token inside the submit-score function instead of trusting a playerId field in the request body.

What is the realistic cost for a small indie game with a few thousand daily players?
Based on current published pricing for Lambda, DynamoDB on-demand, and API Gateway, a few thousand daily active players checking and submitting scores a handful of times each typically lands well under $10 a month in total backend cost, excluding WebSocket connection-minutes if players stay connected for long sessions.

Can this run on Azure or Google Cloud instead?
The same pattern maps to Azure Functions with Azure SignalR Service and Cosmos DB, or Google Cloud Functions with Firestore and a WebSocket-compatible service, but the specific Terraform resources and SDK calls in this tutorial are AWS-specific.

Marcus Chen

Marcus Chen

Gaming & Consumer Tech Editor

Marcus Chen is a senior editor at Tech Insider, where he leads coverage of the US online gaming market, including sweepstakes and social casinos, alongside consumer technology. He evaluates operators on their published terms, licensing and RNG certifications, stated redemption policies, and corroborating independent reporting, and writes plainly about what the evidence supports. Tech Insider does not run first-party money tests and does not gamble with reader funds. Marcus has reported on the technology and online-gaming industries for more than a decade.

View all articles