Case Study · Web & Data

MetArenaStats

Tier lists for a League of Legends game mode that no stats site covers properly. Built end to end on the Riot API.

60k+matches ingested
217k+players tracked
~11klines of TypeScript
165commits since 6 Sept 2026

Figures from September 2026. The crawler is still running.

The problem

Arena is League of Legends' 3v3 mode: six teams of three, a shared pool of augments and prismatic items that exist nowhere else in the game, and a placement from 1st to 6th instead of a win or a loss.

The stats sites people actually use are all built for Summoner's Rift. They report win rates, which Arena doesn't have, and they rank champions by 5v5 role, which Arena doesn't have either. Augments decide most Arena games and there is almost no public data on them at all. Players pick from a three-way choice, several times a game, on feel.

So the site answers the questions the mode actually raises: which champions place well, which augments are worth taking at each rarity, which items to build, and which pairings and three-champion comps place together. Everything is ranked on average placement rather than on a win rate that doesn't exist.

There is also no ladder for the mode, because Riot doesn't expose an Arena MMR anywhere. The site computes its own rating from the matches it tracks, which gives Arena a leaderboard it didn't have before.

The stack

Everything had to keep running on free tiers while the data volume grew. That constraint turned out to drive most of the decisions further down this page.

Frontend & rendering

Next.js 16 · React 19 · TypeScript

App Router, server components, Tailwind CSS 4. Stats pages are statically regenerated (ISR) so the CDN absorbs nearly every visit; only player pages stay dynamic, because they hit the Riot API live.

Database

Supabase (Postgres)

Raw matches and participants, a crawl queue, precomputed stat snapshots, plus SQL functions and a materialized view for the counters and the leaderboard. Those are the two places where moving work into Postgres actually paid off.

Data collection

Riot Games API · GitHub Actions

A snowball crawler and a timeline pass, both running as long-lived GitHub Actions jobs against rate-limited endpoints. Runner minutes are free on a public repo; the API key is the scarce resource.

Hosting & delivery

Vercel · Cloudflare DNS

Deployed from main, served at metarenastats.tblabs.dev. Cron-triggered API routes refresh the snapshots, protected by a shared secret.

Working the Riot API

This is where most of the engineering went. The API belongs to someone else, it has a hard rate limit, and the data it returns often isn't shaped the way the documentation suggests.

Read the limits from the responses, not the portal

Riot returns the live state of every counter in the headers of every response. Reading them from there means the numbers are always current, including on the day the key changes:

x-app-rate-limit:       100:120,20:1   ← the key: 100 req/2min AND 20 req/s
x-app-rate-limit-count: 3:120,1:1      ← where we currently stand
x-method-rate-limit:    2000:10        ← this endpoint only

Two counters stack and the stricter one wins. Watching them in the responses turned up something the portal doesn't mention: the app counter is per regional host. The europe host sat at 3:120 while euw1 sat at 2:120, so they are two independent budgets. Anything served from euw1 (summoner profiles, status) costs nothing against the match budget, and that changed what was worth building.

The client wraps every call in a sliding-window limiter, one instance per host, that retries on 429 using Retry-After and re-syncs itself against the count header on every response. Across three chained searches (~96 calls), two 429s were hit. Both were retried and no matches were lost, where the previous version had dropped them silently.

That fix exposed a second problem the plan hadn't anticipated: a third search took 117 seconds. The limiter was doing its job, but a blank page for two minutes is worse than a clean error. Interactive calls now give up after 15 seconds and return a "too many searches" message instead. The crawler has nobody waiting on it, so it is still allowed to sit and wait.

How the crawler finds matches

Start from a queue of known player IDs, seeded in one call from a challenger league endpoint that returns 300 of them and sits on the free host.

Fetch that player's Arena match IDs.

Diff against the database and keep only the new ones. This is what makes the whole thing viable. Discovery costs almost nothing, and calls are only spent on matches we don't already have.

Fetch the match, store its 18 participants, and push the 17 other players into the queue.

Repeat. Every match harvested brings in 17 new players, so the queue never runs dry. It went from 0 to 217,000 tracked players this way.

What the data actually contains

A match returns each player's item0…item6, which looks like build order and isn't. It is the order of the inventory slots. Cross-checked against the timeline endpoint on 4,363 players, the first inventory slot was the first purchase only 37.5% of the time, so two thirds of the old "first item" stat was noise.

The timeline endpoint gives the real purchase order, but it costs 1.48 MB per match, ten times the match itself, plus one extra API call, which halves crawl throughput. So it runs as a separate catch-up pass instead of inline, and it regulates itself: when the crawl slows down, the catch-up speeds up.

The timeline has a gap of its own: prismatic items never appear in it. They come out of an anvil rather than the shop, so what gets recorded is the purchase of the anvil, not what came out of it. One player finished a game with four prismatics and no timeline entries at all. That shaped the schema: purchase order for legendaries and boots, final inventory for prismatics, with both sources kept.

Patches had a similar catch. Riot writes gameVersion into every match, and that turns out to matter a lot: the crawler is constantly discovering players and pulling their full history, so a match ingested today may have been played in May. Ingestion date tells you nothing about patch. The short patch number is a Postgres generated column, so two builds of the same patch group together instead of splitting the tier lists into slices too thin to use.

Four decisions, with the numbers behind them

Each of these started with a measurement rather than a hunch. Twice, the measurement killed the plan I had already written.

Stop recomputing everything on every page view

Every stats page was force-dynamic and loaded the entire participants table into memory to aggregate in JavaScript. A page view cost 3.7 MB of database egress, and a hard-coded page cap meant that past ~1,660 matches the numbers would have gone silently wrong, truncated with no error at all.

Aggregators now run once per cycle in a cron job and write their output to a snapshot table, so pages read a few KB. Truncation is logged, stored, and fails the job. Worth noting that the aggregation itself was never the slow part: all nine aggregators run in 155 ms.

3.7 MB per page view → a few KB

61% of the traffic was two columns

The refresh job still re-read the whole table each cycle, and that, not page views, was what would hit the 5 GB/month free egress ceiling. Measuring the compressed row as it actually travels showed that two columns accounted for 61% of it: the player IDs, 78 random characters that don't compress, needed by exactly one aggregator.

They moved into SQL functions. The leaderboard snapshot had a problem of the same kind: 3.6 MB on its own, more than a full read of the table, because 14,131 of the 17,588 tracked players had played a single game. At one game you score either 0% or 100% top 3, so those rows ranked nobody. A five-game floor cut the snapshot by 96%.

ceiling at ~14,000 matches → ~50,000 matches

The SQL rewrite that never happened

The obvious next step was to pre-aggregate in Postgres so that nothing leaves the database. Counting it first: the raw rows are 27,558, but the groups they generate (champion pairs, item combinations) come to 483,693. Pre-aggregating would have cost 17 times more to transport than sending the raw rows.

A participant row is already a very compact encoding: 7 items and 5 augments in 31 bytes, against one row each for the ~100 pairs they generate. The rewrite was dropped and three reference tables built for it were deleted unused.

raw 27,558 rows → pre-aggregated 483,693 rows

One trigger has to be worth several hours

GitHub's scheduled workflows were firing 6 times in 16 hours instead of 16. Rather than guess at why, I lined up the timestamps of two crons that were set 26 minutes apart, and they were going off in pairs, minutes from each other. GitHub isn't evaluating each cron separately. It samples the repository every 2 to 6 hours. No minute setting changes that, so after checking and discarding six other hypotheses I changed the design instead.

The job became a loop running to a deadline instead of a fixed number of passes. Runner time is free and unlimited on a public repo, while the Riot key is the scarce resource, and it's shared with visitors searching their own name. Optimising the wrong side of that was the original mistake.

~50% of the key in bursts → ~25% continuously, and more matches per day
It came out better on both counts at once: more matches ingested, and more headroom left for visitors at any given moment. When two constraints look like they're in tension, it's worth checking which of the two resources is actually scarce.

How it was built: iterating with Claude Code

I'm not a developer by trade. MetArenaStats was built by directing an AI coding agent, Claude Code, over roughly 165 commits and two weeks. What follows is less about the agent writing the code than about what I had to do myself to get something that actually runs in production.

The plan lives in the repo, including what went wrong

The data pipeline is specified in a versioned document that sits next to the code, phase by phase. When reality disagrees with it, the document doesn't get rewritten: the original design stays, and the correction goes underneath it with the measurement that forced it. Three of the decisions above exist only because the plan was wrong and the record of why survived. It's also what stops an agent with no memory of last week from proposing an idea that was already tested and rejected.

Measure first, then let it write

The rule that worked best: no optimisation gets written before the thing it optimises has been measured. A row count cancelled the SQL rewrite. A measurement of what actually travels turned an architecture change into a two-column fix. An agent will happily implement any plausible plan you hand it, so deciding which plan is worth implementing is the part that stays with you.

Define what "it works" means before starting

Moving a computation must not change a number that's already published. So the egress refactor was checked by running the old and the new code against identical data and diffing the output: 181 of the 182 snapshots came out bit-identical, the 182nd being the leaderboard, which had deliberately changed. Settling on that check before starting is what made the result verifiable instead of just plausible.

Own the product decisions

Some constants aren't tuning knobs. The crawler's per-pass match cap stays where it is on purpose, because raising it would eat into the API budget shared with visitors searching their own name, trading real user experience for ingestion speed. Soloq ranks were ruled out of scope altogether, since a soloq rank says nothing about Arena skill, and that gap is exactly what the site's own leaderboard exists to fill. Both are product calls, and they're written down as such so that no agent quietly reverses them.

Why I build these

I'm a functional consultant moving into Solutions Engineering. Projects like this one are how I keep the technical half of that job hands-on: reading what an API actually does, working out what a design costs before committing to it, and being able to explain both to the people who have to decide.