DEV Community

kamedashe
kamedashe

Posted on

I built an anime backlog calculator and broke my own app three different ways

I track what I watch. My backlog grew past 40 titles and I had no idea what
that actually meant in hours. Every tracker (MyAnimeList, AniList, Shikimori)
shows you a list — none of them multiply it out into something you can
actually plan your week around.

So I built TokiWa — Next.js 15, Prisma, Postgres on
Neon. The core feature is dumb-simple: remaining_episodes × episode_duration,
shown as real hours, with a "what fits in an evening / a weekend / a
vacation" breakdown. Nobody in the anime-tracker space does this. Turns out
general TV trackers like MyShows had something similar years ago — readers
on a post I wrote pointed that out, which was a good reminder that "unique"
claims deserve a footnote.

Here's what actually went wrong while building it.

Postgres thinks NULL is the biggest number there is

Sorting the catalog by score, descending:

\ts
orderBy: { score: "desc" }
\
\

Titles with no score yet (unreleased anime) floated to the very top of the
homepage, ahead of a 9.1-rated classic. Postgres puts NULLs first on a
DESC sort by default — I'd never hit this because every other sortable
field I'd used was NOT NULL. Fix:

\ts
orderBy: { score: { sort: "desc", nulls: "last" } }
\
\

Same bug had picked the homepage hero title too, via a separate query.
Grepped for every orderBy: { score\ in the codebase to be sure I got them
all.

A sitemap can be too big to deploy

Catalog grew to 15k+ titles. Sitemap grew with it — to 24MB. Vercel caps
prerendered static files at 19MB. Every deploy after that point failed
silently in a way that took a minute to diagnose. Split it into shards of
2000 titles each via Next's generateSitemaps()\:

\ts
export async function generateSitemaps() {
const count = await prisma.title.count();
const shards = 1 + Math.ceil(count / TITLES_PER_SITEMAP);
return Array.from({ length: shards }, (_, id) => ({ id }));
}
\
\

The sitemap index at /sitemap.xml\ still resolves — search consoles never
needed re-submitting.

Bulk-syncing 600 pages of data exhausted the connection pool

Backfilling the catalog from an external API meant one query per title to
check if it already existed — fine at small scale, catastrophic at 30,000
candidate titles over several hours. Neon's pool has a hard connection
limit; a few hours in, everything started failing with:

\
Timed out fetching a new connection from the connection pool.
\
\

The fix wasn't more connections — it was fewer round trips. One
findMany({ where: { malId: { in: ids } } })\ per batch of 50 instead of
50 individual findUnique\ calls. Also added a retry wrapper so one flaky
page doesn't kill a multi-hour run:

\ts
async function withRetry<T>(action: () => Promise<T>, attempts = 4) {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await action();
} catch (error) {
if (attempt === attempts) return null;
await new Promise((r) => setTimeout(r, attempt * 3000));
}
}
}
\
\

The data source silently started redirecting every request

One of the external APIs I depend on switched its canonical domain and
began 301-redirecting every single call. fetch()\ follows redirects
silently, so nothing broke — it just got slower, one wasted round trip
per request, invisible until I actually looked at response headers.
Pointed the client straight at the new domain and moved on.


TokiWa is free, no forced signup to browse, no ads. Catalog re-syncs
itself nightly now via a cron job so it doesn't need me babysitting it.
If you poke around and find something broken — the catalog is big enough
that edge cases are definitely still hiding somewhere — I'd genuinely like
to hear about it.

https://www.tokiwa.moe



Top comments (0)