
DynamoDB Data Modeling Challenge #4 - Results
Highlighting successful data models submitted to The Infinite Leaderboard — Creative Competition at Global Scale
A billion fans. Unlimited creativity. Challenge 4 asked developers to design a DynamoDB-backed platform supporting an unbounded number of named leaderboards, each handling over 1,000 score updates per second, while still answering four read patterns: the top N on a board, a specific participant's rank and score, the records neighboring a participant, and every leaderboard a participant appears on. In this post we'll take a deeper dive into some entries for this challenge.
This was the most technically demanding challenge in the series. It forces a collision between two things DynamoDB will not give you on the same key at the same time. To sustain more than 1,000 writes per second you have to spread writes across many partitions, because a single partition caps at 1,000 WCU/sec. But to rank participants you need them in one sorted collection, and DynamoDB only sorts within a single partition, never across them. You cannot have both on one key. Every strong submission is, at its core, a decision about where to absorb that tension.
Three submissions stood out, and they are best understood as the same core design tuned for three different priorities. This is a common pattern across many data models. There's more than one way to approach a problem, and the efficiency of those approaches depends significantly on the characteristics of the workload. Sometimes amplifying writes can substantially reduce reads, reducing costs. Sometimes it's the opposite. These interesting differences show up in how these submissions maintain counters - even switch strategies dynamically depending on leaderboard size to optimize as much as possible.
The one idea they all share
Before the differences, the common foundation. All three leading designs key the base table on the participant, not the leaderboard.
The obvious first instinct is
PK = leaderboardName. It reads fine until one leaderboard catches fire during a live match and every score update for it lands on the same partition which then throttles at the 1,000 WCU/sec ceiling. Keying on the participant instead spreads a single leaderboard's writes across as many partitions as there are participants writing to it. A write hot partition (on the base table at least) becomes structurally impossible, no matter how many fans hammer one board, because no two participants share a partition key.This choice also aligns with the fourth access pattern. "Every leaderboard a participant appears on" becomes a single query on that participant's partition. It needs no secondary index and no scatter-gather.
The second shared idea: rank is computed, not stored. Nobody keeps a
rank attribute on the participant item, because a single new high score near the top of a large board shifts the rank of everyone beneath it, which would mean rewriting millions of items for one update. Rank is instead derived on read by counting how many participants scored higher. All three count into score buckets rather than counting participants one by one, and all three key those bucket counters with the bucket in the sort key and a shard in the partition key, so "every bucket above mine" is a range query rather than an item-by-item enumeration. That shared skeleton is where the similarity ends. How each one keeps those bucket counters accurate is the main thing that separates them, and it is a real three-way split.1. Participant-Keyed Writes and a Stream-Maintained Multi-Level Histogram
G Monish Reddy:
The-Infinite-Leaderboard
The-Infinite-Leaderboard
The most complete engineering package of the round. It ships working service code, a DynamoDB Local test harness running 2,255 assertions against an in-memory reference implementation, and all three required deliverables generated programmatically from the live code so they cannot drift from what actually runs.
The write path and the sharding formula
The base table is participant-keyed, so no hot keys. Sharding is applied only to the ranking GSI, which is where leaderboard-level grouping has to be reassembled. The shard count comes from a formula:
1
shards = next_power_of_two(ceil(peak_wps × 2 / 700))The
× 2 is write amplification, and is a detail many submissions missed. When you update a participant's score, you are changing the attribute the GSI uses as its sort key, and DynamoDB implements a GSI sort-key change as a delete of the old index entry plus an insert of the new one, which is two index writes, not one. So one logical score update costs three write units: one on the base table and two on the index. Sizing shards against peak_wps / 1000 therefore overstates real capacity by roughly 2x. The ÷ 700 instead of ÷ 1000 leaves about 30 percent headroom below the partition ceiling to absorb hash skew, since deterministic hashing does not spread participants perfectly evenly. He measured the busiest shard at 1.07x the average, 533 WCU/sec at 4,000 writes/sec across 16 shards, comfortably under 1,000.The counter structure
The histogram counters all live under one partition key per (leaderboard, counter-shard), with the level and bucket encoded in the sort key (
T0#B12345, T1#B123, T2#B1). Coarse and fine counters are co-located, not scattered across partitions. This matters, because it means reading "all the coarse buckets above you" is a single range query billed by bytes read, not one request per bucket.The histogram is multi-level: level 0 counts participants per fine bucket, level 1 per group of
fanout level-0 buckets, and so on. Counting everyone above a score is a walk up the levels that takes a bounded partial range at each one. A worked example, for a score of 12345 with level widths (1, 100, 10000):1
2
3
level 0 buckets 12346..12399 -> scores 12346..12399
level 1 buckets 124..199 -> scores 12400..19999
level 2 buckets 2..max -> scores 20000..Those three ranges tile "everything above 12345" exactly once, with no overlap and no gap, and each range is capped at
fanout buckets because the next coarser level absorbs everything past the current level's partial span. That is the point of the extra levels. Without them, a participant near the bottom of a wide score range would have to read every occupied bucket above them, which on a finely bucketed board is a large read. The levels cap the work so read cost scales with the logarithm of the score range rather than with the number of buckets above the participant. For a board with only a few hundred buckets this is overkill and a single-level histogram would read the same data just as cheaply; the multi-level structure earns its place specifically on wide, densely occupied score ranges.Keeping the counters safe under write load
The counters are maintained off the write path by a DynamoDB Streams consumer, and this is the key to why co-locating them does not create a write hot-spot. The aggregator batches a window of score changes and coalesces them into one
ADD per moved bucket per window. A leaderboard taking 1,000 updates per second would otherwise drive up to 2,000 counter increments per second into a handful of coarse buckets and blow straight through the per-partition ceiling; coalescing makes counter write volume a function of how many distinct buckets moved, not of ingest rate. The ADD is atomic so concurrent aggregator invocations cannot lose increments. The trade-off is that the histogram trails live writes by about one to two seconds.There is a scaling caveat worth naming, because it is the kind of thing that holds at demo scale and quietly erodes as the platform grows. Coalescing only saves a write when two records in the same Lambda batch target the same bucket, and whether that happens is left to chance, not arranged by the key design. Stream shards track the table's physical partitions, and because the base table is keyed on the participant, the records in any one batch are a random scattering of participants across whatever leaderboards and buckets happened to be written in that window. The coalescing ratio is really the batch's density per bucket: high when a single hot leaderboard dominates the write stream and many of its updates land in a few popular buckets, but falling as writes spread across more and more boards. Past some growth point a batch mostly holds one record per distinct bucket, coalescing collapses to roughly one
ADD per crossing, and counter write volume drifts back toward the un-batched rate the design was relying on batching to avoid. It does not break so much as gradually stop helping, and the point where that happens depends on total write spread rather than any single board's traffic. A larger batch window pushes the point out, at the cost of adding directly to the one-to-two-second rank staleness.The three rank strategies, and the real cost of each
Rank is served by three strategies, auto-selected by board size, all verified to produce identical answers against the reference implementation so the routing changes only cost, since each implementation is cost optimized for different leaderboard sizes.
- INDEX_COUNT queries each GSI shard with
Select=COUNTfor entries above the participant and sums. DynamoDB bills a COUNT by items touched, not the number returned, so cost grows with how many people are above you. Cheap near the top of any board and cheap everywhere on a small one. Capped around 20,000 participants. - TREE walks the multi-level histogram described above. Its cost is a handful of range queries, independent of participant count, since bucket structure depends on the score range rather than the population. It reads the co-located counters across the tree's counter-shards, so the real figure is a small number of byte-billed queries rather than a single fixed constant.
- LADDER is the read-scaling answer. The aggregator rolls the histogram into a cumulative distribution, a compact blob that is identical for every fan asking about the same board, and application hosts cache it and binary-search it in memory. When the ladder is built at level 0, where each bucket holds exactly one score, a rank lookup costs zero DynamoDB reads while the cached blob is warm. That is what lets a board being refreshed by millions answer rank without the reads reaching the table. The cost is a one-to-two-second staleness and a dependency on warm hosts.
For top-N, Monish also materializes a snapshot item rebuilt about once per second by the aggregator, so the common case is a single point read rather than a scatter-gather on every request. For score encoding he uses a hand-rolled scheme covering seven value types including mixed-direction composites, proven with over 2,000 assertions, which buys flexibility at the cost of a bespoke encoding surface he has to test exhaustively.
2. Transactional Bucket Counters and a Live, Load-Tested Deployment
Yogesh S:
infinite-leaderboard
infinite-leaderboard
Letting the database do the ordering
The most distinctive table-level choice is leaning on DynamoDB's November 2025 multi-attribute composite sort key rather than hand-rolling one. Every ranking design needs the GSI sorted by score, then broken ties by participant. The usual way is to build a sortable string by hand: zero-pad the score to a fixed width so lexical order matches numeric order, invert it for descending order, and append the participant ID as a tie-breaker. Each of those steps is a place to introduce a bug, especially around negative scores and boundary ties. His design doc notes that an earlier draft did exactly this and that adversarial review caught both the negative-score padding bug and a boundary-tie sentinel bug. The native composite key
(score, participantId) removes the whole category: DynamoDB orders a real numeric attribute correctly including negatives, and a BETWEEN on score alone naturally includes every participant tied at either boundary, with no sentinel and no constraint on participant-ID characters. Sort direction is handled purely by ScanIndexForward, so the stored score is always the participant's real value.The base table is keyed
PARTICIPANT#<id> / LB#<name>, so each participant occupies their own partition and the "all my leaderboards" pattern is a single query on the participant's partition. GSI1 is sharded LB#<name>#SHARD#<n> with n = deterministicHash(participantId) % N, defaulting to N=8. His shard-sizing explicitly accounts for the same GSI write amplification Monish does, treating the effective per-shard ceiling as 500 writes/sec rather than 1,000, and he lays out the N=2/4/8/16 headroom trade-off and picks 8 as a deliberate midpoint between hash-skew headroom and read-side scatter-gather fan-out.The counter maintenance is transactional, not streamed
This is where Yogesh differs most sharply from Monish. His bucket counters are not maintained asynchronously off a stream, but are updated inside the same transaction as the score write using a read-check-transact flow. Since an
ADD score :delta is a relative increment, the client cannot know which bucket to move without first reading the current score, so each score change does:- GetItem the participant's current score, strongly consistent, which also yields a version value for an optimistic lock.
- Compute the old and new bucket IDs.
- Choose the smallest transaction that covers what actually changed:
- First write: create the score item plus increment the new bucket counter. Two items.
- Same-bucket update, the common case since most score ticks do not cross a bucket boundary: update only the score item under the version condition. One item, no bucket write at all.
- Bucket-crossing update: update the score item plus decrement the old bucket counter plus increment the new one. Three items.
- If the version condition fails because a concurrent write to the same participant raced, retry.
The consequence is that the bucket counters are transactionally consistent with the score at every moment. There is no staleness window on the rank data the way there is with a stream-fed histogram. The counters' own write distribution is handled by splitting each bucket's count across M random sub-shards, which is correct here precisely because counts are commutative, unlike a participant's own score item which must live in one place to stay atomically updatable. The bucket is in the sort key and the sub-shard in the partition key, so computing a rank is M parallel range queries,
SK > myBucket, summed, bounded by the fixed B×M counter keyspace rather than by participant count.Compared with Monish's design the trade is clear. Yogesh pays for transactional currency on the hot write path: a bucket-crossing score change is a three-item transaction, and TransactWriteItems bills at 2x the normal per-item WCU, which his cost section works through to roughly 6 WCU for the common same-bucket case and 10 WCU for a crossing. In exchange, rank never lags the score. Monish pays nothing extra on the write path because the histogram work is batched onto a stream, but his rank data is a second or two behind. One bought currency with write cost; the other bought a cheap write path with staleness.
One cost the design does not fully carry into its sizing math is what that transactional currency does to the counter partitions themselves. A bucket-crossing write issues its
ADDs inside the transaction, so each counter update bills at 2 WCU, which halves a counter sub-shard's real ceiling from roughly 1,000 to roughly 500 crossings per second. His sub-shard count M is sized by argument from how rare crossings are (the same-bucket fast path means most score ticks touch no counter at all), not from a per-sub-shard WCU ceiling, and the 2x transaction multiplier is not folded into it. Scores cluster, so a popular mid-distribution bucket absorbs a disproportionate share of crossings, and that hot bucket's safe write rate is really M × ~500 transactional crossings per second, not M × 1,000. The same-bucket fast path makes this unlikely to bite in practice, but the multiplier belongs in the sizing rather than left to the rarity argument alone.Exact where it matters, approximate where it does not
Yogesh's rank is approximate by default: the coarse term is exact to the bucket, and within the participant's own bucket he uses linear interpolation rather than an exact count, with an exact single-bucket count offered as an option. He leans on a product observation to justify this: a participant near the top wants their literal position, while a participant deep in the tail only wants a sense of standing. His design gets that split for the price of top-N, since anyone who falls inside the already-computed top-N slice has their exact rank as a byproduct of that query, and the bucket approximation is only ever used below the cutoff where an approximate answer is the right fit anyway.
Proof it runs
This is the only submission you can click and run. A live CloudFront demo with a "Scale test" button sits on a full CDK stack: a Lambda per access pattern, API Gateway HTTP and WebSocket APIs, Cognito guest identities, EventBridge, and Streams. The measured results were 1,000 concurrent invocations with zero throttled requests, zero system errors, and bursts up to 6,062 WCU in a single one-minute window. The writeup is candid that everything that broke was the test harness, not the table: a Lambda concurrency quota that was 10 in one region and 1,000 in another, a client sharing one HTTP/2 connection pool across worker threads, and an IAM gap visible only in CloudWatch Logs. Rank changes are also pushed to browsers through Streams to EventBridge to a WebSocket API when a participant crosses a bucket boundary. The one stretch goal left on the table is non-integer score types, which the native composite key supports structurally but which were not demonstrated.
3. Rank Without Storing Rank, Maintained Inline
Joel Anarba Amuni:
infinite-leaderboard
infinite-leaderboard
The same foundation
This submission names the core insight directly: rank does not have to be stored, it can be calculated efficiently when needed. The base table PK embeds the full participant ID (
LB#<name>#PART#<participantId>), which makes write hot partitions impossible, and a GSI sharded by md5(participantId) % K provides the ranked view. Joel notes the choice of MD5 over Python's built-in hash(). Python randomizes string hashing per process by default, so hash(participantId) % K would place the same participant on different shards across Lambda invocations and break the deterministic placement that makes a participant's own score a single-item read and write. MD5 is stable across processes.How the counters are maintained
Joel's histogram is single-level, keyed
PK = LB#<name>#HIST#<shard>, SK = BKT#<bucket>, with the shard chosen by a deterministic hash of the participant. Counters are kept accurate with a third distinct model from the other two submissions. The counters are updated inline and synchronously in the score write itself, not off a stream and not inside a transaction. The code only touches the histogram when a score actually crosses a bucket boundary: if the old and new buckets are equal it does nothing, and otherwise it issues two ADD operations, one to decrement the old bucket and one to increment the new. Most incremental score updates change nothing in the histogram, so they cost zero bucket writes.This sits between the other two. Unlike Monish's stream aggregator, the bucket writes are on the request path and are not coalesced, so a bucket that many participants cross into at once can see contended counter writes, mitigated only by the per-participant hash spreading them across the histogram shards. Unlike Yogesh, the score write and the two bucket
ADDs are separate operations rather than one transaction, so they are not atomic with each other, though Joel's rank read recomputes the fine term exactly on the index, which limits the impact of a transient counter skew.Three strategies, routed by snapshot freshness
Joel's
rank.py ships three strategies, routed differently from Monish's size-based routing:- direct counts index entries above the participant across the write-shards. Exact, cheapest near the top, impractical deep in a large board.
- histogram is the default. The coarse term sums the single-level bucket counters strictly above the participant's bucket, as one range query per histogram shard, and the fine term counts exactly within the participant's own bucket on the GSI. Because the fine term is always counted exactly rather than interpolated, this strategy returns an exact rank, and its cost is bounded independently of participant count. The difference from Monish's TREE is the single versus multi-level structure: on a wide, densely occupied score range Joel's coarse query reads more bucket items than Monish's tiled walk would, though for a normally sized bucket range the two are comparable. Joel's single-level structure also consumes fewer counter-maintenance WCUs than Monish's multi-level tree, most noticeably when new participants join a board — a first appearance writes one counter for Joel versus one per level for Monish. Which structure is optimal depends on a combination of the board's mix of new-participant growth versus churn among existing participants, the overall board size, and the read volume.
- snapshot is a cached cumulative distribution, built by a background job that reads the histogram once and writes a single item holding a
cumAbovemap. A rank lookup is one GetItem for the coarse term plus the exact fine term, stale by the rebuild interval. The router uses a fresh snapshot when one exists and is younger than ten seconds, and otherwise falls back to the exact histogram path.
This is similar to Monish's LADDER with one substantial difference. Both precompute a cumulative distribution, but Joel's snapshot is a DynamoDB item fetched with a GetItem on each lookup, whereas Monish's ladder is held in process memory and answered with no table read while warm. Joel's is simpler and has no warm-host assumption; Monish's is cheaper under extreme read volume.
The Bigger Picture
The strongest submissions converged on keying the base table on the participant, not the leaderboard. That single decision removes the hot-partition problem from the base table entirely and makes the "all my leaderboards" pattern a single-partition query with no secondary index. The leaderboard grouping then lives only on a GSI, where it can be sharded for the ranked view. Several developers arrived at this independently, which is a strong signal it is the right shape for the problem.
The second shared lesson is that rank is an aggregation, not an attribute, and all strong designs computed it by counting into score buckets whose counters are keyed for range reads. For top-N, pulling the top N from each shard is provably sufficient, because no item ranked worse than N within its own shard can be in the global top N, which bounds the read to shard_count × N.
The strongest designs also accounted for GSI write amplification. Updating an indexed sort key is billed as a delete plus an insert, so a leaderboard's real write ceiling is about shard_count × 500 WCU/sec, not shard_count × 1,000. A design that misses it overstates throughput by roughly 2x.
Where the Approaches Diverge
The three share the foundation above and differ mainly in how the bucket counters that back rank are kept accurate. The earlier sections cover each in detail; the table below is the summary.
| Axis | Monish | Yogesh | Joel |
|---|---|---|---|
| Counter maintenance | Stream-batched, coalesced | Inside the score-write transaction | Inline, non-transactional |
| Benefit | Cheap, ingest-independent writes | Transactionally current rank | Simplicity, no stream or txn |
| Trade-off | 1–2s staleness | 2x WCU per crossing | Score and counters not atomic |
| Rank exactness | Exact (stale on cached LADDER) | Approximate by default | Exact by default |
| Histogram | Multi-level (helps wide ranges) | Single-level | Single-level |
| Score encoding | Hand-rolled, 7 types | Native composite key, integers | Explicit int/decimal/duration |
| Top-N read | Materialized snapshot (point read) | Scatter-gather | Scatter-gather |
Taken together, the three are the same core design tuned for three priorities: Monish for read cost at extreme scale, Yogesh for consistency and proven operation, and Joel for exact rank with the least moving parts. Which one is right depends on whether your real constraint is read volume, rank freshness, or operational simplicity.
Winners
All submitted models that passed requirements:
Join the NoSQL Launchpad in Builder Center , where AWS NoSQL specialists share knowledge, review designs, and help teams pressure-test their DynamoDB configurations before the stakes are real.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article