DEV Community

yyj-dev
yyj-dev

Posted on

The Pokédex Is 2.4MB. My Users Never Download It.

A random Pokémon generator is a strange thing to optimize. The entire product is: press a button, get a Pokémon. But behind that one button sits the whole dataset of the franchise — 1,025 species across nine generations, an 18×18 type chart, hundreds of moves and abilities, and a learnset table for every single species. The naive versions of this app either hammer a public API on every click or quietly ship a multi-megabyte database to every visitor. I spent most of my architecture budget on a random Pokémon generator I built making sure it does neither, and the pattern that came out of it — treat your build step as a data compiler — is the part worth writing down.

Why runtime API calls were never on the table

PokéAPI is a lovely public resource, but it answers questions one at a time, and a generator doesn't ask questions one at a time. When someone toggles "Gen 3 only, no Legendaries, starters excluded" and hits the button, the app needs the entire filtered pool in memory right now. Fetching per click means spinners, rate-limit anxiety, and being a bad citizen toward a free API.

So at runtime, my site makes zero calls to PokéAPI. Instead, the excellent @pkmn/dex npm package sits in devDependencies — it never ships to anyone — and a build-time script, scripts/generate-pokedex.mjs, runs it once and emits plain static JSON that the app imports like any other module:

  • pokedex.json — 1,025 species, exactly 13 fields each
  • typechart.json — the full 18×18 effectiveness matrix
  • moves.json — 685 moves
  • abilities.json — 310 abilities
  • learnsets.json — the legal move list for each of the 1,025 species

The motivation is a pair of numbers I refuse to inflict on visitors: the dex package weighs 2.4MB, and the raw learnsets data alone is a 3.1MB chunk. Neither ever appears in the client bundle. What ships instead is the trimmed JSON — 13 fields per species, nothing the UI doesn't render or filter on. The dex knows a thousand things about each Pokémon; my users' phones only need to know thirteen of them.

Editorial decisions belong in the generator script

Here's the part I didn't expect: deciding what counts as a Pokémon is genuinely messy, and I'm glad that mess lives in one script instead of being sprinkled across components.

The raw dex data includes battle-only formes, cosmetic variants, fan-designed CAP species, and other entries that would feel like bugs if they popped out of a generator. My script hardcodes the policy in one place:

  • skip anything with a non-empty forme, and cosmetic formes generally
  • skip entries with num <= 0
  • skip anything whose isNonstandard is CAP, LGPE, Custom, Unobtainable, or Future

Some classifications simply don't exist in the data, so I made them exist. There is no isStarter flag anywhere — "starter" is a fan concept — so the script carries a hardcoded Set of the 27 starter names and stamps a boolean onto each matching species. Legendary and mythical status comes from tags. Generation isn't stored either; it's derived from National Dex number ranges (number ≤ 151 → Gen 1, and so on up through Gen 9).

The client never reasons about any of this. It reads pre-computed booleans and fields. If I ever change my mind about what belongs in the pool, that's a one-file diff and a rebuild, not a scavenger hunt through UI code.

The one file too big to import

After trimming, learnsets.json still weighs 870KB. That's small compared to the 3.1MB it came from, but it's still enormous next to everything else — and only one feature on the entire site needs it: the moveset generator.

So it's the one file that never gets a static import. It loads through await import(), dynamically, the first time someone actually generates a moveset. The visitor who came to spin up random Gen 1 teams never downloads a byte of it. The visitor who wants movesets pays the cost exactly once, on first use, and the chunk is cached after that.

The same isolation logic applies at a smaller scale: moves and abilities live in two separate lib files, not one shared "battle data" module. The moveset page doesn't bundle abilities.json; the ability page doesn't bundle moves.json. It's the kind of split that feels fussy until you look at a bundle analyzer and see each route carrying only its own weight.

Strings you can compute don't belong in JSON

One more trim that surprised me with how much it mattered: sprite URLs aren't stored in the data at all. A thousand-entry JSON file where every entry carries even one URL string is a thousand copies of the same CDN prefix. Since sprite filenames are just the National Dex number, the URL is assembled at runtime from the number the app already has. The JSON stores an integer; the string exists only in memory, briefly, on its way into an img tag.

The most boring line of code I've ever shipped

After all of that — the build pipeline, the filtering policy, the chunk isolation — here is the actual "generator":

pool[Math.floor(Math.random() * pool.length)]
Enter fullscreen mode Exit fullscreen mode

That's it. That's the feature. No seeded PRNG, no weighting, no cleverness. The team generator adds exactly one wrinkle: when it draws a Pokémon for a six-slot team, it splices the pick out of a working copy of the pool, so the same species can't appear twice. Draw, remove, repeat six times.

I find this genuinely funny. The line users think of as "the app" took thirty seconds to write. Everything around it — making sure pool is small, correct, filtered to their settings, and arrived on their device without dragging megabytes of dex data along — took weeks.

The takeaway

If your side project is really a dataset wearing a UI, the highest-leverage code you'll write probably isn't in a component. It's a script that runs at build time, reads a heavyweight source of truth from devDependencies, applies every editorial decision you'd otherwise scatter through the app, and emits the smallest possible artifacts for each route to import — with anything both large and rarely-used pushed behind a dynamic import().

The browser gets answers, not the encyclopedia the answers came from.

(Standard footnote: this is an independent fan project, not affiliated with Nintendo, Game Freak, or The Pokémon Company. The data pipeline is the part I can take credit for; the 1,025 reasons anyone visits are theirs.)

Top comments (0)