The client message was short and chilling: "all api keys for sanity and coingecko have run out." The site in question, a client site with market-data features built on Next.js 16, had zero real visitors at the time. Not low traffic. None. Yet the CoinGecko usage projection pointed at roughly 1.7 million calls per month, far past the 500K ceiling of the Analyst plan. Sanity was even worse by ratio: about 860K writes per month against a 200K limit.
Zero visitors. 1.7 million calls. Those two numbers do not belong in the same sentence, and that contradiction is where this story starts.
The dramatic guesses, all wrong
My first instincts were the dramatic ones. A leaked API key being abused by someone else. An infinite loop somewhere. Aggressive scraping hammering the site. Every one of those hypotheses shared the same shape: blame something extraordinary.
The reality was far more boring. Nothing had leaked and nothing had crashed. Every line of code was doing exactly what it was written to do. The only thing that had never happened was simple: nobody ever sat down and ran the math.
The root cause: three factors multiplying
Sitting in the fetcher code was this constant:
// feels live without burning quota
const CACHE_TTL_SECONDS = 15;The comment explains the intent: 15 seconds so the data feels live without burning quota. The problem is that the "without burning quota" half of that claim was never tested against arithmetic. Once you actually compute it, three factors multiply into each other.
Factor one: a 15-second TTL means every cache key can refetch up to four times a minute. That is the baseline.
Factor two, and the sneakiest of the three: cache fragmentation. The per-coin and per-NFT endpoints do not produce one cache key. Each produces 100+ keys, one per resource. A TTL protects per key, not per endpoint. So "one endpoint with a 15-second cache" is really hundreds of 15-second timers running in parallel.
Factor three: zero visitors does not mean zero traffic. Bot crawlers, a forgotten dev tab still polling, monitoring, sitemap regeneration, all of it keeps running before the first human ever shows up. One bot crawl pass touching N pages means N fresh fetches in every TTL window.
Multiply the three together and 1.7 million a month stops looking mysterious.
The Sanity leak took a different path to the same theme. provider-status.ts wrote 2 Sanity requests on every single fetcher invocation, and that fetcher was called from 88 call sites. Every fetch, including the millions triggered by the fragmentation above, also logged its status. That is where the 860K writes-per-month projection came from.
The fix: run the math first, then pick the number
The TTL for shared endpoints went from 15 to 60 seconds, and the constant now has to carry its own justification:
// CACHE_TTL_SECONDS = 60. Single cache key (shared top-100). Caps at 60x24x30/60s = 43K calls/month max. Analyst limit 500K. OK.
const CACHE_TTL_SECONDS = 60;That comment is not decoration. It is the new required format: every TTL constant must state its key cardinality and its monthly projection. Per-resource fetchers, the ones with hundreds of keys, moved to 300 seconds. The combined projection dropped to about 250K calls per month, comfortably under the 500K ceiling.
For Sanity, the provider-status writes got throttled with a Redis lock on Upstash using the SET NX EX pattern:
const lockKey = `provider-status-lock:${outcome}:${provider}`;
const acquired = await redis.set(lockKey, "1", { nx: true, ex: 300 });
if (!acquired) return; // this status was already recorded in the last 5 minutesOne write per outcome-and-provider combination every five minutes, no matter how often the fetcher fires. The projection fell from 860K to roughly 14K writes per month.
Two permanent rules came out of this incident: every TTL gets a cardinality-plus-projection comment, and per-resource fetchers get a TTL of at least 5 minutes.
The takeaway
- A TTL without a monthly projection is not configuration, it is a guess. The formula is cheap: key count times refresh frequency times a month of runtime.
- Cache fragmentation is a hidden multiplier. A TTL protects per key, so count the keys your endpoint actually creates, not the endpoints you have.
- Zero visitors is not zero traffic. Bots, monitoring, and a polling dev tab are enough to blow a quota before launch.
- Side effects on the fetch path multiply too. Two "small" writes per invocation become hundreds of thousands when the invocations number in the millions.
- Paid API quota is the client's bill. Do the math before you deploy, not after the "keys have run out" message arrives.
